Skip to main content

damascene_core/
math.rs

1//! Native math expression IR and box layout.
2//!
3//! This module is intentionally presentation-oriented. It is shaped like
4//! MathML Core because that is the interchange target Damascene wants to accept,
5//! but layout lowers into TeX-style boxes: width, ascent, descent, and a flat
6//! list of positioned glyph/rule atoms.
7
8use std::ops::Range;
9use std::sync::Arc;
10
11use crate::text::metrics as text_metrics;
12use crate::tree::{Color, FontFamily, FontWeight, Rect, TextWrap};
13
14const DEFAULT_RULE_THICKNESS: f32 = 1.1;
15const SCRIPT_SCALE: f32 = 0.72;
16const LARGE_OPERATOR_SCALE: f32 = 1.35;
17const FRACTION_PAD_EM: f32 = 0.18;
18const FRACTION_GAP_EM: f32 = 0.18;
19const SQRT_GAP_EM: f32 = 0.10;
20const TABLE_COL_GAP_EM: f32 = 0.8;
21const TABLE_ROW_GAP_EM: f32 = 0.35;
22const CASES_COL_GAP_EM: f32 = 0.5;
23const RADICAL_GLYPH: char = '√';
24const THIN_MATH_SPACE_EM: f32 = 0.08;
25const MEDIUM_MATH_SPACE_EM: f32 = 0.18;
26const THICK_MATH_SPACE_EM: f32 = 0.28;
27const STRETCHY_VARIANT_CHARS: [char; 29] = [
28    '(',
29    ')',
30    '[',
31    ']',
32    '{',
33    '}',
34    '|',
35    '‖',
36    '⌊',
37    '⌋',
38    '⌈',
39    '⌉',
40    RADICAL_GLYPH,
41    '∑',
42    '∫',
43    '∏',
44    '⋂',
45    '⋃',
46    '∐',
47    '∮',
48    '∬',
49    '∭',
50    '⨁',
51    '⨂',
52    '⨀',
53    '⋁',
54    '⋀',
55    '⨄',
56    '⨆',
57];
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
60pub enum MathDisplay {
61    #[default]
62    Inline,
63    Block,
64}
65
66#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
67pub enum MathColumnAlignment {
68    Left,
69    #[default]
70    Center,
71    Right,
72}
73
74#[derive(Clone, Debug, PartialEq)]
75#[non_exhaustive]
76pub enum MathExpr {
77    Row(Vec<MathExpr>),
78    Identifier(String),
79    Number(String),
80    Operator(String),
81    OperatorWithMetadata {
82        text: String,
83        lspace: Option<f32>,
84        rspace: Option<f32>,
85        large_operator: Option<bool>,
86        movable_limits: Option<bool>,
87    },
88    Text(String),
89    Space(f32),
90    Fraction {
91        numerator: Arc<MathExpr>,
92        denominator: Arc<MathExpr>,
93    },
94    Sqrt(Arc<MathExpr>),
95    Root {
96        base: Arc<MathExpr>,
97        index: Arc<MathExpr>,
98    },
99    Scripts {
100        base: Arc<MathExpr>,
101        sub: Option<Arc<MathExpr>>,
102        sup: Option<Arc<MathExpr>>,
103    },
104    UnderOver {
105        base: Arc<MathExpr>,
106        under: Option<Arc<MathExpr>>,
107        over: Option<Arc<MathExpr>>,
108    },
109    Accent {
110        base: Arc<MathExpr>,
111        accent: Arc<MathExpr>,
112        stretch: bool,
113    },
114    Fenced {
115        open: Option<String>,
116        close: Option<String>,
117        body: Arc<MathExpr>,
118    },
119    Table {
120        rows: Vec<Vec<MathExpr>>,
121        column_alignments: Vec<MathColumnAlignment>,
122        column_gap: Option<f32>,
123        row_gap: Option<f32>,
124    },
125    Source {
126        source: Range<usize>,
127        body: Arc<MathExpr>,
128    },
129    Error(String),
130}
131
132impl MathExpr {
133    pub fn row(children: impl IntoIterator<Item = MathExpr>) -> Self {
134        let mut children: Vec<MathExpr> = children.into_iter().collect();
135        match children.len() {
136            0 => MathExpr::Row(Vec::new()),
137            1 => children.pop().unwrap(),
138            _ => MathExpr::Row(children),
139        }
140    }
141
142    pub fn source_range(&self) -> Option<&Range<usize>> {
143        match self {
144            MathExpr::Source { source, .. } => Some(source),
145            _ => None,
146        }
147    }
148
149    pub fn without_source(&self) -> &MathExpr {
150        match self {
151            MathExpr::Source { body, .. } => body.without_source(),
152            _ => self,
153        }
154    }
155}
156
157#[derive(Clone, Debug, PartialEq)]
158pub struct MathLayout {
159    pub width: f32,
160    pub ascent: f32,
161    pub descent: f32,
162    pub atoms: Vec<MathAtom>,
163}
164
165impl MathLayout {
166    pub fn height(&self) -> f32 {
167        self.ascent + self.descent
168    }
169}
170
171#[derive(Clone, Debug, PartialEq)]
172pub enum MathAtom {
173    Glyph {
174        text: String,
175        x: f32,
176        y_baseline: f32,
177        size: f32,
178        weight: FontWeight,
179        italic: bool,
180    },
181    GlyphId {
182        glyph_id: u16,
183        rect: Rect,
184        view_box: Rect,
185    },
186    Rule {
187        rect: Rect,
188    },
189    Radical {
190        points: [[f32; 2]; 5],
191        thickness: f32,
192    },
193    Delimiter {
194        delimiter: String,
195        rect: Rect,
196        thickness: f32,
197    },
198}
199
200#[derive(Clone, Copy, Debug, PartialEq, Eq)]
201enum MathOperatorClass {
202    Ordinary,
203    Binary,
204    Relation,
205    Large,
206    Punctuation,
207}
208
209#[derive(Clone, Copy, Debug, PartialEq)]
210struct MathOperatorInfo {
211    class: MathOperatorClass,
212    lspace_em: f32,
213    rspace_em: f32,
214    large_operator: bool,
215    movable_limits: bool,
216}
217
218impl MathOperatorInfo {
219    fn new(class: MathOperatorClass, lspace_em: f32, rspace_em: f32) -> Self {
220        Self {
221            class,
222            lspace_em,
223            rspace_em,
224            large_operator: false,
225            movable_limits: false,
226        }
227    }
228
229    fn large(mut self) -> Self {
230        self.large_operator = true;
231        self.movable_limits = true;
232        self
233    }
234
235    fn large_with_side_scripts(mut self) -> Self {
236        self.large_operator = true;
237        self.movable_limits = false;
238        self
239    }
240}
241
242fn operator_info(operator: &str) -> MathOperatorInfo {
243    use MathOperatorClass::*;
244    match operator {
245        "+" | "-" | "±" | "∓" | "·" | "×" | "÷" | "∪" | "∩" | "∧" | "∨" | "⊕" | "⊖" | "⊗" | "⊘"
246        | "⊙" | "⋆" | "∗" | "∘" | "•" | "⊔" | "⊓" | "⨿" | "≀" | "◁" | "▷" | "⋄" | "∖" => {
247            MathOperatorInfo::new(Binary, MEDIUM_MATH_SPACE_EM, MEDIUM_MATH_SPACE_EM)
248        }
249        "=" | "<" | ">" | "≤" | "≥" | "≠" | "≈" | "∼" | "→" | "←" | "↔" | "⇒" | "⇐" | "⇔" | "⟹"
250        | "⟸" | "⟺" | "⟶" | "⟵" | "⟷" | "↦" | "⟼" | "↪" | "↩" | "∈" | "∉" | "∋" | "∌" | "⊂"
251        | "⊃" | "⊆" | "⊇" | "⊊" | "⊋" | "≡" | "≃" | "≅" | "∝" | "≺" | "≻" | "⪯" | "⪰" | "≪"
252        | "≫" | "∥" | "⊥" | "≍" | "≐" | "⊨" | "⊢" | "⊣" | "∴" | "∵" => {
253            MathOperatorInfo::new(Relation, MEDIUM_MATH_SPACE_EM, MEDIUM_MATH_SPACE_EM)
254        }
255        "∑" | "∏" | "⋂" | "⋃" | "∐" | "⨁" | "⨂" | "⨀" | "⋁" | "⋀" | "⨄" | "⨆" => {
256            MathOperatorInfo::new(Large, THIN_MATH_SPACE_EM, THIN_MATH_SPACE_EM).large()
257        }
258        "∫" | "∮" | "∬" | "∭" => {
259            MathOperatorInfo::new(Large, THIN_MATH_SPACE_EM, THIN_MATH_SPACE_EM)
260                .large_with_side_scripts()
261        }
262        "," | "." | ";" | ":" => MathOperatorInfo::new(Punctuation, 0.0, THIN_MATH_SPACE_EM),
263        _ => MathOperatorInfo::new(Ordinary, 0.0, 0.0),
264    }
265}
266
267#[derive(Clone, Copy, Debug)]
268struct LayoutCtx {
269    size: f32,
270    display: MathDisplay,
271}
272
273impl LayoutCtx {
274    fn script(self) -> Self {
275        Self {
276            size: self.metrics().script_size(),
277            display: MathDisplay::Inline,
278        }
279    }
280
281    fn large_operator(self) -> Self {
282        Self {
283            size: self.metrics().large_operator_size(),
284            display: self.display,
285        }
286    }
287
288    fn metrics(self) -> MathMetrics {
289        MathMetrics {
290            size: self.size,
291            display: self.display,
292        }
293    }
294}
295
296#[derive(Clone, Copy, Debug)]
297struct MathMetrics {
298    size: f32,
299    display: MathDisplay,
300}
301
302impl MathMetrics {
303    fn font_constants(self) -> Option<OpenTypeMathConstants> {
304        open_type_math_constants()
305    }
306
307    fn script_size(self) -> f32 {
308        self.font_constants()
309            .and_then(|constants| constants.script_scale(self.size))
310            .unwrap_or(self.size * SCRIPT_SCALE)
311            .max(6.0)
312    }
313
314    fn large_operator_size(self) -> f32 {
315        self.size * LARGE_OPERATOR_SCALE
316    }
317
318    fn rule_thickness(self) -> f32 {
319        self.font_constants()
320            .and_then(|constants| constants.fraction_rule_thickness(self.size))
321            .unwrap_or(DEFAULT_RULE_THICKNESS * self.size / 16.0)
322            .max(0.75)
323    }
324
325    fn radical_rule_thickness(self) -> f32 {
326        self.font_constants()
327            .and_then(|constants| constants.radical_rule_thickness(self.size))
328            .unwrap_or_else(|| self.rule_thickness())
329            .max(0.75)
330    }
331
332    fn default_ascent(self) -> f32 {
333        self.size * 0.75
334    }
335
336    fn default_descent(self) -> f32 {
337        self.size * 0.25
338    }
339
340    fn glyph_ascent(self) -> f32 {
341        self.size * 0.82
342    }
343
344    fn glyph_descent(self) -> f32 {
345        self.size * 0.22
346    }
347
348    fn space_width(self, em: f32) -> f32 {
349        self.size * em
350    }
351
352    fn operator_spacing_with_overrides(
353        self,
354        operator: &str,
355        lspace_em: Option<f32>,
356        rspace_em: Option<f32>,
357    ) -> (f32, f32) {
358        let info = operator_info(operator);
359        (
360            self.size * lspace_em.unwrap_or(info.lspace_em),
361            self.size * rspace_em.unwrap_or(info.rspace_em),
362        )
363    }
364
365    fn fraction_pad(self) -> f32 {
366        self.size
367            * if matches!(self.display, MathDisplay::Block) {
368                FRACTION_PAD_EM
369            } else {
370                FRACTION_PAD_EM * 0.65
371            }
372    }
373
374    fn fraction_numerator_gap(self) -> f32 {
375        self.font_constants()
376            .and_then(|constants| {
377                constants
378                    .fraction_numerator_gap(self.size, matches!(self.display, MathDisplay::Block))
379            })
380            .unwrap_or_else(|| self.fraction_gap_fallback())
381    }
382
383    fn fraction_denominator_gap(self) -> f32 {
384        self.font_constants()
385            .and_then(|constants| {
386                constants
387                    .fraction_denominator_gap(self.size, matches!(self.display, MathDisplay::Block))
388            })
389            .unwrap_or_else(|| self.fraction_gap_fallback())
390    }
391
392    fn fraction_gap_fallback(self) -> f32 {
393        self.size
394            * if matches!(self.display, MathDisplay::Block) {
395                FRACTION_GAP_EM
396            } else {
397                FRACTION_GAP_EM * 0.55
398            }
399    }
400
401    fn fraction_numerator_shift(self) -> f32 {
402        self.font_constants()
403            .and_then(|constants| {
404                constants
405                    .fraction_numerator_shift(self.size, matches!(self.display, MathDisplay::Block))
406            })
407            .unwrap_or(self.size * 0.55)
408    }
409
410    fn fraction_denominator_shift(self) -> f32 {
411        self.font_constants()
412            .and_then(|constants| {
413                constants.fraction_denominator_shift(
414                    self.size,
415                    matches!(self.display, MathDisplay::Block),
416                )
417            })
418            .unwrap_or(self.size * 0.55)
419    }
420
421    fn math_axis_shift(self) -> f32 {
422        self.font_constants()
423            .and_then(|constants| constants.axis_height(self.size))
424            .or_else(|| {
425                matches!(self.display, MathDisplay::Block)
426                    .then(|| self.operator_axis_shift())
427                    .flatten()
428            })
429            .unwrap_or(self.size * 0.28)
430    }
431
432    fn operator_axis_shift(self) -> Option<f32> {
433        let layout = math_glyph_layout("+", self.size, FontWeight::Regular);
434        let baseline = layout.lines.first()?.baseline;
435        Some((baseline - layout.line_height * 0.5).max(self.size * 0.2))
436    }
437
438    fn sqrt_gap(self) -> f32 {
439        self.font_constants()
440            .and_then(|constants| {
441                constants
442                    .radical_vertical_gap(self.size, matches!(self.display, MathDisplay::Block))
443            })
444            .unwrap_or(self.size * SQRT_GAP_EM)
445    }
446
447    fn radical_width(self) -> f32 {
448        self.size * 0.72
449    }
450
451    fn radical_left_flair_y(self) -> f32 {
452        -self.size * 0.03
453    }
454
455    fn radical_hook_x(self) -> f32 {
456        self.size * 0.12
457    }
458
459    fn radical_hook_y(self) -> f32 {
460        -self.size * 0.1
461    }
462
463    fn radical_tick_x(self) -> f32 {
464        self.size * 0.24
465    }
466
467    fn radical_tick_y(self, inner_descent: f32) -> f32 {
468        (inner_descent * 0.75).max(self.size * 0.13)
469    }
470
471    fn radical_variant_for_height(self, target_height: f32) -> Option<OpenTypeDelimiterVariant> {
472        self.stretchy_variant_for_height(RADICAL_GLYPH, target_height)
473    }
474
475    fn large_operator_variant_for_height(
476        self,
477        operator: &str,
478        target_height: f32,
479    ) -> Option<OpenTypeDelimiterVariant> {
480        let operator = single_char(operator)?;
481        is_large_operator_symbol(operator)
482            .then(|| self.stretchy_variant_for_height(operator, target_height))?
483    }
484
485    fn root_offset_x(self, index_width: f32) -> f32 {
486        self.font_constants()
487            .map(|constants| {
488                let before = constants
489                    .radical_kern_before_degree(self.size)
490                    .unwrap_or(0.0);
491                let after = constants
492                    .radical_kern_after_degree(self.size)
493                    .unwrap_or(0.0);
494                (before + index_width + after).max(index_width * 0.35)
495            })
496            .unwrap_or(index_width * 0.55)
497    }
498
499    fn root_index_shift(self, root_ascent: f32, index_descent: f32) -> f32 {
500        self.font_constants()
501            .and_then(|constants| constants.radical_degree_bottom_raise_fraction())
502            .map(|raise| -root_ascent * raise - index_descent)
503            .unwrap_or(-root_ascent * 0.52)
504    }
505
506    fn script_gap(self) -> f32 {
507        self.font_constants()
508            .and_then(|constants| constants.space_after_script(self.size))
509            .unwrap_or(self.size * 0.06)
510    }
511
512    fn superscript_shift(self, base_ascent: f32, sup_descent: f32) -> f32 {
513        let min_shift = self
514            .font_constants()
515            .and_then(|constants| constants.superscript_shift_up(self.size))
516            .unwrap_or(0.0);
517        let bottom_min = self
518            .font_constants()
519            .and_then(|constants| constants.superscript_bottom_min(self.size))
520            .unwrap_or(self.size * 0.18);
521        -(base_ascent * 0.58)
522            .max(min_shift)
523            .max(sup_descent + bottom_min)
524    }
525
526    fn subscript_shift(self, base_descent: f32, sub_ascent: f32) -> f32 {
527        let min_shift = self
528            .font_constants()
529            .and_then(|constants| constants.subscript_shift_down(self.size))
530            .unwrap_or(self.size * 0.28);
531        (base_descent + sub_ascent * 0.72).max(min_shift)
532    }
533
534    fn sub_superscript_gap(self) -> f32 {
535        self.font_constants()
536            .and_then(|constants| constants.sub_superscript_gap_min(self.size))
537            .unwrap_or(self.size * 0.08)
538    }
539
540    fn under_over_gap(self) -> f32 {
541        self.size * 0.12
542    }
543
544    fn upper_limit_gap(self) -> f32 {
545        self.font_constants()
546            .and_then(|constants| constants.upper_limit_gap_min(self.size))
547            .unwrap_or_else(|| self.under_over_gap())
548    }
549
550    fn upper_limit_baseline_rise(self) -> f32 {
551        self.font_constants()
552            .and_then(|constants| constants.upper_limit_baseline_rise_min(self.size))
553            .unwrap_or(self.size * 0.35)
554    }
555
556    fn lower_limit_gap(self) -> f32 {
557        self.font_constants()
558            .and_then(|constants| constants.lower_limit_gap_min(self.size))
559            .unwrap_or_else(|| self.under_over_gap())
560    }
561
562    fn lower_limit_baseline_drop(self) -> f32 {
563        self.font_constants()
564            .and_then(|constants| constants.lower_limit_baseline_drop_min(self.size))
565            .unwrap_or(self.size * 0.35)
566    }
567
568    fn accent_gap(self) -> f32 {
569        self.size * 0.06
570    }
571
572    fn table_col_gap(self, gap_em: Option<f32>) -> f32 {
573        self.size * gap_em.unwrap_or(TABLE_COL_GAP_EM)
574    }
575
576    fn table_row_gap(self, gap_em: Option<f32>) -> f32 {
577        self.size * gap_em.unwrap_or(TABLE_ROW_GAP_EM)
578    }
579
580    fn delimiter_gap(self) -> f32 {
581        self.size * 0.08
582    }
583
584    fn delimiter_overshoot(self) -> f32 {
585        (self.size * 0.08).max(self.rule_thickness()).max(
586            self.font_constants()
587                .and_then(|constants| constants.min_connector_overlap(self.size))
588                .unwrap_or(0.0),
589        )
590    }
591
592    fn delimited_sub_formula_min_height(self) -> f32 {
593        self.font_constants()
594            .and_then(|constants| constants.delimited_sub_formula_min_height(self.size))
595            .unwrap_or(self.size * 1.5)
596    }
597
598    fn should_stretch_delimiter(self, body: &MathLayout) -> bool {
599        body.height() + self.delimiter_overshoot() * 2.0 >= self.delimited_sub_formula_min_height()
600    }
601
602    fn delimiter_variant_for_height(
603        self,
604        delimiter: char,
605        target_height: f32,
606    ) -> Option<OpenTypeDelimiterVariant> {
607        self.stretchy_variant_for_height(delimiter, target_height)
608    }
609
610    fn stretchy_variant_for_height(
611        self,
612        glyph: char,
613        target_height: f32,
614    ) -> Option<OpenTypeDelimiterVariant> {
615        self.font_constants().and_then(|constants| {
616            constants.stretchy_variant_for_height(glyph, target_height, self.size)
617        })
618    }
619
620    fn delimiter_assembly_parts(
621        self,
622        delimiter: char,
623    ) -> Option<Vec<OpenTypeDelimiterAssemblyPart>> {
624        self.font_constants()
625            .and_then(|constants| constants.delimiter_assembly_parts(delimiter))
626    }
627
628    fn delimiter_width(self) -> f32 {
629        self.size * 0.42
630    }
631}
632
633#[derive(Clone, Debug)]
634struct OpenTypeMathConstants {
635    units_per_em: f32,
636    script_percent_scale_down: i16,
637    axis_height: i16,
638    subscript_shift_down: i16,
639    superscript_shift_up: i16,
640    superscript_bottom_min: i16,
641    sub_superscript_gap_min: i16,
642    space_after_script: i16,
643    upper_limit_gap_min: i16,
644    upper_limit_baseline_rise_min: i16,
645    lower_limit_gap_min: i16,
646    lower_limit_baseline_drop_min: i16,
647    fraction_numerator_shift_up: i16,
648    fraction_numerator_display_style_shift_up: i16,
649    fraction_denominator_shift_down: i16,
650    fraction_denominator_display_style_shift_down: i16,
651    fraction_rule_thickness: i16,
652    fraction_numerator_gap_min: i16,
653    fraction_num_display_style_gap_min: i16,
654    fraction_denominator_gap_min: i16,
655    fraction_denom_display_style_gap_min: i16,
656    radical_rule_thickness: i16,
657    radical_vertical_gap: i16,
658    radical_display_style_vertical_gap: i16,
659    radical_kern_before_degree: i16,
660    radical_kern_after_degree: i16,
661    radical_degree_bottom_raise_percent: i16,
662    delimited_sub_formula_min_height: u16,
663    min_connector_overlap: u16,
664    #[cfg_attr(not(test), allow(dead_code))]
665    delimiter_variants: Vec<OpenTypeDelimiterVariants>,
666}
667
668#[cfg_attr(not(test), allow(dead_code))]
669#[derive(Clone, Debug)]
670struct OpenTypeDelimiterVariants {
671    delimiter: char,
672    variants: Vec<OpenTypeDelimiterVariant>,
673    assembly_parts: Vec<OpenTypeDelimiterAssemblyPart>,
674}
675
676#[cfg_attr(not(test), allow(dead_code))]
677#[derive(Clone, Copy, Debug)]
678struct OpenTypeDelimiterVariant {
679    glyph_id: u16,
680    advance: u16,
681    horizontal_advance: u16,
682    bbox: Option<OpenTypeGlyphBBox>,
683}
684
685#[cfg_attr(not(test), allow(dead_code))]
686#[derive(Clone, Copy, Debug)]
687struct OpenTypeDelimiterAssemblyPart {
688    glyph_id: u16,
689    start_connector_length: u16,
690    end_connector_length: u16,
691    full_advance: u16,
692    horizontal_advance: u16,
693    bbox: Option<OpenTypeGlyphBBox>,
694    extender: bool,
695}
696
697#[derive(Clone, Copy, Debug)]
698struct OpenTypeGlyphBBox {
699    x_min: i16,
700    y_min: i16,
701    x_max: i16,
702    y_max: i16,
703}
704
705impl OpenTypeDelimiterVariants {
706    fn max_advance(&self) -> u16 {
707        self.variants
708            .iter()
709            .map(|variant| variant.advance)
710            .chain(self.assembly_parts.iter().map(|part| part.full_advance))
711            .max()
712            .unwrap_or(0)
713    }
714}
715
716impl OpenTypeMathConstants {
717    fn font_units(&self, value: i16, size: f32) -> Option<f32> {
718        (value > 0 && self.units_per_em > 0.0).then(|| value as f32 / self.units_per_em * size)
719    }
720
721    fn signed_font_units(&self, value: i16, size: f32) -> Option<f32> {
722        (value != 0 && self.units_per_em > 0.0).then(|| value as f32 / self.units_per_em * size)
723    }
724
725    fn script_scale(&self, size: f32) -> Option<f32> {
726        (self.script_percent_scale_down > 0)
727            .then(|| size * self.script_percent_scale_down as f32 / 100.0)
728    }
729
730    fn fraction_rule_thickness(&self, size: f32) -> Option<f32> {
731        self.font_units(self.fraction_rule_thickness, size)
732    }
733
734    fn axis_height(&self, size: f32) -> Option<f32> {
735        self.font_units(self.axis_height, size)
736    }
737
738    fn subscript_shift_down(&self, size: f32) -> Option<f32> {
739        self.font_units(self.subscript_shift_down, size)
740    }
741
742    fn superscript_shift_up(&self, size: f32) -> Option<f32> {
743        self.font_units(self.superscript_shift_up, size)
744    }
745
746    fn superscript_bottom_min(&self, size: f32) -> Option<f32> {
747        self.font_units(self.superscript_bottom_min, size)
748    }
749
750    fn sub_superscript_gap_min(&self, size: f32) -> Option<f32> {
751        self.font_units(self.sub_superscript_gap_min, size)
752    }
753
754    fn space_after_script(&self, size: f32) -> Option<f32> {
755        self.font_units(self.space_after_script, size)
756    }
757
758    fn upper_limit_gap_min(&self, size: f32) -> Option<f32> {
759        self.font_units(self.upper_limit_gap_min, size)
760    }
761
762    fn upper_limit_baseline_rise_min(&self, size: f32) -> Option<f32> {
763        self.font_units(self.upper_limit_baseline_rise_min, size)
764    }
765
766    fn lower_limit_gap_min(&self, size: f32) -> Option<f32> {
767        self.font_units(self.lower_limit_gap_min, size)
768    }
769
770    fn lower_limit_baseline_drop_min(&self, size: f32) -> Option<f32> {
771        self.font_units(self.lower_limit_baseline_drop_min, size)
772    }
773
774    fn fraction_numerator_shift(&self, size: f32, display: bool) -> Option<f32> {
775        let value = if display {
776            self.fraction_numerator_display_style_shift_up
777        } else {
778            self.fraction_numerator_shift_up
779        };
780        self.font_units(value, size)
781    }
782
783    fn fraction_denominator_shift(&self, size: f32, display: bool) -> Option<f32> {
784        let value = if display {
785            self.fraction_denominator_display_style_shift_down
786        } else {
787            self.fraction_denominator_shift_down
788        };
789        self.font_units(value, size)
790    }
791
792    fn fraction_numerator_gap(&self, size: f32, display: bool) -> Option<f32> {
793        let value = if display {
794            self.fraction_num_display_style_gap_min
795        } else {
796            self.fraction_numerator_gap_min
797        };
798        self.font_units(value, size)
799    }
800
801    fn fraction_denominator_gap(&self, size: f32, display: bool) -> Option<f32> {
802        let value = if display {
803            self.fraction_denom_display_style_gap_min
804        } else {
805            self.fraction_denominator_gap_min
806        };
807        self.font_units(value, size)
808    }
809
810    fn radical_rule_thickness(&self, size: f32) -> Option<f32> {
811        self.font_units(self.radical_rule_thickness, size)
812    }
813
814    fn radical_vertical_gap(&self, size: f32, display: bool) -> Option<f32> {
815        let value = if display {
816            self.radical_display_style_vertical_gap
817        } else {
818            self.radical_vertical_gap
819        };
820        self.font_units(value, size)
821    }
822
823    fn radical_kern_before_degree(&self, size: f32) -> Option<f32> {
824        self.signed_font_units(self.radical_kern_before_degree, size)
825    }
826
827    fn radical_kern_after_degree(&self, size: f32) -> Option<f32> {
828        self.signed_font_units(self.radical_kern_after_degree, size)
829    }
830
831    fn radical_degree_bottom_raise_fraction(&self) -> Option<f32> {
832        (self.radical_degree_bottom_raise_percent > 0)
833            .then(|| self.radical_degree_bottom_raise_percent as f32 / 100.0)
834    }
835
836    #[cfg_attr(not(test), allow(dead_code))]
837    fn delimiter_variant_count(&self, delimiter: char) -> usize {
838        self.delimiter_variants
839            .iter()
840            .find(|variants| variants.delimiter == delimiter)
841            .map(|variants| variants.variants.len())
842            .unwrap_or(0)
843    }
844
845    #[cfg_attr(not(test), allow(dead_code))]
846    fn delimiter_assembly_part_count(&self, delimiter: char) -> usize {
847        self.delimiter_variants
848            .iter()
849            .find(|variants| variants.delimiter == delimiter)
850            .map(|variants| variants.assembly_parts.len())
851            .unwrap_or(0)
852    }
853
854    #[cfg_attr(not(test), allow(dead_code))]
855    fn delimiter_max_advance(&self, delimiter: char, size: f32) -> Option<f32> {
856        let advance = self
857            .delimiter_variants
858            .iter()
859            .find(|variants| variants.delimiter == delimiter)?
860            .max_advance();
861        (advance > 0 && self.units_per_em > 0.0).then(|| advance as f32 / self.units_per_em * size)
862    }
863
864    #[cfg_attr(not(test), allow(dead_code))]
865    fn delimiter_extender_part_count(&self, delimiter: char) -> usize {
866        self.delimiter_variants
867            .iter()
868            .find(|variants| variants.delimiter == delimiter)
869            .map(|variants| {
870                variants
871                    .assembly_parts
872                    .iter()
873                    .filter(|part| part.extender)
874                    .count()
875            })
876            .unwrap_or(0)
877    }
878
879    fn stretchy_variant_for_height(
880        &self,
881        glyph: char,
882        target_height: f32,
883        size: f32,
884    ) -> Option<OpenTypeDelimiterVariant> {
885        let variants = self
886            .delimiter_variants
887            .iter()
888            .find(|variants| variants.delimiter == glyph)?;
889        variants.variants.iter().copied().find(|variant| {
890            self.units_per_em > 0.0
891                && variant.advance as f32 / self.units_per_em * size >= target_height
892        })
893    }
894
895    fn delimiter_assembly_parts(
896        &self,
897        delimiter: char,
898    ) -> Option<Vec<OpenTypeDelimiterAssemblyPart>> {
899        let variants = self
900            .delimiter_variants
901            .iter()
902            .find(|variants| variants.delimiter == delimiter)?;
903        (!variants.assembly_parts.is_empty()).then(|| variants.assembly_parts.clone())
904    }
905
906    #[cfg_attr(not(test), allow(dead_code))]
907    fn delimiter_first_variant_glyph_id(&self, delimiter: char) -> Option<u16> {
908        self.delimiter_variants
909            .iter()
910            .find(|variants| variants.delimiter == delimiter)?
911            .variants
912            .first()
913            .map(|variant| variant.glyph_id)
914    }
915
916    #[cfg_attr(not(test), allow(dead_code))]
917    fn delimiter_has_assembly_connectors(&self, delimiter: char) -> bool {
918        self.delimiter_variants
919            .iter()
920            .find(|variants| variants.delimiter == delimiter)
921            .is_some_and(|variants| {
922                variants.assembly_parts.iter().any(|part| {
923                    part.glyph_id > 0
924                        && (part.start_connector_length > 0 || part.end_connector_length > 0)
925                })
926            })
927    }
928
929    fn min_connector_overlap(&self, size: f32) -> Option<f32> {
930        (self.min_connector_overlap > 0 && self.units_per_em > 0.0)
931            .then(|| self.min_connector_overlap as f32 / self.units_per_em * size)
932    }
933
934    fn delimited_sub_formula_min_height(&self, size: f32) -> Option<f32> {
935        (self.delimited_sub_formula_min_height > 0 && self.units_per_em > 0.0)
936            .then(|| self.delimited_sub_formula_min_height as f32 / self.units_per_em * size)
937    }
938}
939
940fn open_type_math_constants() -> Option<OpenTypeMathConstants> {
941    #[cfg(feature = "symbols")]
942    {
943        static CONSTANTS: std::sync::OnceLock<Option<OpenTypeMathConstants>> =
944            std::sync::OnceLock::new();
945        CONSTANTS
946            .get_or_init(|| parse_open_type_math_constants(damascene_fonts::NOTO_SANS_MATH_REGULAR))
947            .clone()
948    }
949    #[cfg(not(feature = "symbols"))]
950    {
951        None
952    }
953}
954
955#[cfg(feature = "symbols")]
956fn parse_open_type_math_constants(font: &[u8]) -> Option<OpenTypeMathConstants> {
957    let face = ttf_parser::Face::parse(font, 0).ok()?;
958    let math = face.tables().math?;
959    let constants = math.constants?;
960    Some(OpenTypeMathConstants {
961        units_per_em: face.units_per_em() as f32,
962        script_percent_scale_down: constants.script_percent_scale_down(),
963        axis_height: constants.axis_height().value,
964        subscript_shift_down: constants.subscript_shift_down().value,
965        superscript_shift_up: constants.superscript_shift_up().value,
966        superscript_bottom_min: constants.superscript_bottom_min().value,
967        sub_superscript_gap_min: constants.sub_superscript_gap_min().value,
968        space_after_script: constants.space_after_script().value,
969        upper_limit_gap_min: constants.upper_limit_gap_min().value,
970        upper_limit_baseline_rise_min: constants.upper_limit_baseline_rise_min().value,
971        lower_limit_gap_min: constants.lower_limit_gap_min().value,
972        lower_limit_baseline_drop_min: constants.lower_limit_baseline_drop_min().value,
973        fraction_numerator_shift_up: constants.fraction_numerator_shift_up().value,
974        fraction_numerator_display_style_shift_up: constants
975            .fraction_numerator_display_style_shift_up()
976            .value,
977        fraction_denominator_shift_down: constants.fraction_denominator_shift_down().value,
978        fraction_denominator_display_style_shift_down: constants
979            .fraction_denominator_display_style_shift_down()
980            .value,
981        fraction_rule_thickness: constants.fraction_rule_thickness().value,
982        fraction_numerator_gap_min: constants.fraction_numerator_gap_min().value,
983        fraction_num_display_style_gap_min: constants.fraction_num_display_style_gap_min().value,
984        fraction_denominator_gap_min: constants.fraction_denominator_gap_min().value,
985        fraction_denom_display_style_gap_min: constants
986            .fraction_denom_display_style_gap_min()
987            .value,
988        radical_rule_thickness: constants.radical_rule_thickness().value,
989        radical_vertical_gap: constants.radical_vertical_gap().value,
990        radical_display_style_vertical_gap: constants.radical_display_style_vertical_gap().value,
991        radical_kern_before_degree: constants.radical_kern_before_degree().value,
992        radical_kern_after_degree: constants.radical_kern_after_degree().value,
993        radical_degree_bottom_raise_percent: constants.radical_degree_bottom_raise_percent(),
994        delimited_sub_formula_min_height: constants.delimited_sub_formula_min_height(),
995        min_connector_overlap: math
996            .variants
997            .map(|variants| variants.min_connector_overlap)
998            .unwrap_or(0),
999        delimiter_variants: parse_open_type_delimiter_variants(&face, math.variants),
1000    })
1001}
1002
1003#[cfg(feature = "symbols")]
1004fn parse_open_type_delimiter_variants(
1005    face: &ttf_parser::Face<'_>,
1006    variants: Option<ttf_parser::math::Variants<'_>>,
1007) -> Vec<OpenTypeDelimiterVariants> {
1008    let Some(variants) = variants else {
1009        return Vec::new();
1010    };
1011    STRETCHY_VARIANT_CHARS
1012        .into_iter()
1013        .filter_map(|delimiter| {
1014            let glyph = face.glyph_index(delimiter)?;
1015            let construction = variants.vertical_constructions.get(glyph)?;
1016            let glyph_variants = construction
1017                .variants
1018                .into_iter()
1019                .map(|variant| OpenTypeDelimiterVariant {
1020                    glyph_id: variant.variant_glyph.0,
1021                    advance: variant.advance_measurement,
1022                    horizontal_advance: face.glyph_hor_advance(variant.variant_glyph).unwrap_or(0),
1023                    bbox: face.glyph_bounding_box(variant.variant_glyph).map(|bbox| {
1024                        OpenTypeGlyphBBox {
1025                            x_min: bbox.x_min,
1026                            y_min: bbox.y_min,
1027                            x_max: bbox.x_max,
1028                            y_max: bbox.y_max,
1029                        }
1030                    }),
1031                })
1032                .collect();
1033            let assembly_parts = construction
1034                .assembly
1035                .map(|assembly| {
1036                    assembly
1037                        .parts
1038                        .into_iter()
1039                        .map(|part| OpenTypeDelimiterAssemblyPart {
1040                            glyph_id: part.glyph_id.0,
1041                            start_connector_length: part.start_connector_length,
1042                            end_connector_length: part.end_connector_length,
1043                            full_advance: part.full_advance,
1044                            horizontal_advance: face.glyph_hor_advance(part.glyph_id).unwrap_or(0),
1045                            bbox: face.glyph_bounding_box(part.glyph_id).map(|bbox| {
1046                                OpenTypeGlyphBBox {
1047                                    x_min: bbox.x_min,
1048                                    y_min: bbox.y_min,
1049                                    x_max: bbox.x_max,
1050                                    y_max: bbox.y_max,
1051                                }
1052                            }),
1053                            extender: part.part_flags.extender(),
1054                        })
1055                        .collect()
1056                })
1057                .unwrap_or_default();
1058            Some(OpenTypeDelimiterVariants {
1059                delimiter,
1060                variants: glyph_variants,
1061                assembly_parts,
1062            })
1063        })
1064        .collect()
1065}
1066
1067pub fn layout_math(expr: &MathExpr, size: f32, display: MathDisplay) -> MathLayout {
1068    layout_expr(expr, LayoutCtx { size, display })
1069}
1070
1071fn layout_expr(expr: &MathExpr, ctx: LayoutCtx) -> MathLayout {
1072    let metrics = ctx.metrics();
1073    match expr {
1074        MathExpr::Source { body, .. } => layout_expr(body, ctx),
1075        MathExpr::Row(children) => layout_row(children, ctx),
1076        MathExpr::Identifier(s) => layout_glyph(s, ctx, FontWeight::Regular, true),
1077        MathExpr::Number(s) => layout_glyph(s, ctx, FontWeight::Regular, false),
1078        MathExpr::Operator(s) => layout_operator(s, ctx),
1079        MathExpr::OperatorWithMetadata {
1080            text,
1081            lspace,
1082            rspace,
1083            large_operator,
1084            ..
1085        } => layout_operator_with_spacing(text, *lspace, *rspace, *large_operator, ctx),
1086        MathExpr::Text(s) => layout_glyph(s, ctx, FontWeight::Regular, false),
1087        MathExpr::Space(em) => MathLayout {
1088            width: metrics.space_width(*em),
1089            ascent: metrics.default_ascent(),
1090            descent: metrics.default_descent(),
1091            atoms: Vec::new(),
1092        },
1093        MathExpr::Fraction {
1094            numerator,
1095            denominator,
1096        } => layout_fraction(numerator, denominator, ctx),
1097        MathExpr::Sqrt(child) => layout_sqrt(child, ctx),
1098        MathExpr::Root { base, index } => layout_root(base, index, ctx),
1099        MathExpr::Scripts { base, sub, sup } => {
1100            layout_scripts(base, sub.as_deref(), sup.as_deref(), ctx)
1101        }
1102        MathExpr::UnderOver { base, under, over } => {
1103            layout_under_over(base, under.as_deref(), over.as_deref(), ctx)
1104        }
1105        MathExpr::Accent {
1106            base,
1107            accent,
1108            stretch,
1109        } => layout_accent(base, accent, *stretch, ctx),
1110        MathExpr::Fenced { open, close, body } => layout_fenced(open, close, body, ctx),
1111        MathExpr::Table {
1112            rows,
1113            column_alignments,
1114            column_gap,
1115            row_gap,
1116        } => layout_table(rows, column_alignments, *column_gap, *row_gap, ctx),
1117        MathExpr::Error(s) => layout_glyph(s, ctx, FontWeight::Regular, false),
1118    }
1119}
1120
1121fn layout_row(children: &[MathExpr], ctx: LayoutCtx) -> MathLayout {
1122    let mut width = 0.0;
1123    let metrics = ctx.metrics();
1124    let mut ascent: f32 = metrics.default_ascent();
1125    let mut descent: f32 = metrics.default_descent();
1126    let mut atoms = Vec::new();
1127    for child in children {
1128        let child_layout = layout_expr(child, ctx);
1129        translate_atoms(&mut atoms, child_layout.atoms, width, 0.0);
1130        width += child_layout.width;
1131        ascent = ascent.max(child_layout.ascent);
1132        descent = descent.max(child_layout.descent);
1133    }
1134    MathLayout {
1135        width,
1136        ascent,
1137        descent,
1138        atoms,
1139    }
1140}
1141
1142fn layout_glyph(s: &str, ctx: LayoutCtx, weight: FontWeight, italic: bool) -> MathLayout {
1143    if s.is_empty() {
1144        return MathLayout {
1145            width: 0.0,
1146            ascent: 0.0,
1147            descent: 0.0,
1148            atoms: Vec::new(),
1149        };
1150    }
1151    let measured = text_metrics::measure_text(s, ctx.size, weight, false, TextWrap::NoWrap, None);
1152    MathLayout {
1153        width: measured.width,
1154        ascent: ctx.metrics().glyph_ascent(),
1155        descent: ctx.metrics().glyph_descent(),
1156        atoms: vec![MathAtom::Glyph {
1157            text: s.to_string(),
1158            x: 0.0,
1159            y_baseline: 0.0,
1160            size: ctx.size,
1161            weight,
1162            italic,
1163        }],
1164    }
1165}
1166
1167fn layout_operator(s: &str, ctx: LayoutCtx) -> MathLayout {
1168    layout_operator_with_spacing(s, None, None, None, ctx)
1169}
1170
1171fn layout_operator_with_spacing(
1172    s: &str,
1173    lspace: Option<f32>,
1174    rspace: Option<f32>,
1175    large_operator: Option<bool>,
1176    ctx: LayoutCtx,
1177) -> MathLayout {
1178    let use_large_operator = large_operator.unwrap_or_else(|| is_large_operator_symbol_str(s));
1179    let glyph_ctx = if matches!(ctx.display, MathDisplay::Block) && use_large_operator {
1180        ctx.large_operator()
1181    } else {
1182        ctx
1183    };
1184    if matches!(ctx.display, MathDisplay::Block) && use_large_operator {
1185        let operator = MathExpr::OperatorWithMetadata {
1186            text: s.into(),
1187            lspace,
1188            rspace,
1189            large_operator: Some(true),
1190            movable_limits: None,
1191        };
1192        if let Some(layout) = layout_large_operator_variant(&operator, glyph_ctx) {
1193            return layout;
1194        }
1195    }
1196    layout_operator_glyph_with_spacing(s, lspace, rspace, glyph_ctx)
1197}
1198
1199fn layout_operator_glyph_with_spacing(
1200    s: &str,
1201    lspace: Option<f32>,
1202    rspace: Option<f32>,
1203    ctx: LayoutCtx,
1204) -> MathLayout {
1205    let mut layout = layout_glyph(s, ctx, FontWeight::Regular, false);
1206    let (lspace, rspace) = ctx
1207        .metrics()
1208        .operator_spacing_with_overrides(s, lspace, rspace);
1209    if lspace > 0.0 || rspace > 0.0 {
1210        for atom in &mut layout.atoms {
1211            if let MathAtom::Glyph { x, .. } = atom {
1212                *x += lspace;
1213            }
1214        }
1215        layout.width += lspace + rspace;
1216    }
1217    layout
1218}
1219
1220fn layout_operator_expr_glyph_fallback(expr: &MathExpr, ctx: LayoutCtx) -> Option<MathLayout> {
1221    match expr.without_source() {
1222        MathExpr::Operator(s) => Some(layout_operator_glyph_with_spacing(s, None, None, ctx)),
1223        MathExpr::OperatorWithMetadata {
1224            text,
1225            lspace,
1226            rspace,
1227            ..
1228        } => Some(layout_operator_glyph_with_spacing(
1229            text, *lspace, *rspace, ctx,
1230        )),
1231        _ => None,
1232    }
1233}
1234
1235fn layout_fraction(numerator: &MathExpr, denominator: &MathExpr, ctx: LayoutCtx) -> MathLayout {
1236    let metrics = ctx.metrics();
1237    let child_ctx = if matches!(ctx.display, MathDisplay::Block) {
1238        ctx
1239    } else {
1240        ctx.script()
1241    };
1242    let num = layout_expr(numerator, child_ctx);
1243    let den = layout_expr(denominator, child_ctx);
1244    let pad = metrics.fraction_pad();
1245    let num_gap = metrics.fraction_numerator_gap();
1246    let den_gap = metrics.fraction_denominator_gap();
1247    let rule = metrics.rule_thickness();
1248    // The math axis sits above the prose baseline. Keeping the fraction
1249    // rule on that axis makes inline fractions read as part of the line
1250    // instead of hanging mostly below it.
1251    let axis_shift = metrics.math_axis_shift();
1252    let rule_center_y = -axis_shift;
1253    let width = num.width.max(den.width) + pad * 2.0;
1254    let num_x = (width - num.width) * 0.5;
1255    let den_x = (width - den.width) * 0.5;
1256    let num_dy = (rule_center_y - num_gap - rule * 0.5 - num.descent)
1257        .min(-metrics.fraction_numerator_shift());
1258    let den_dy = (rule_center_y + den_gap + rule * 0.5 + den.ascent)
1259        .max(metrics.fraction_denominator_shift());
1260    let ascent = -num_dy + num.ascent;
1261    let descent = den_dy + den.descent;
1262    let mut atoms = Vec::new();
1263    translate_atoms(&mut atoms, num.atoms, num_x, num_dy);
1264    atoms.push(MathAtom::Rule {
1265        rect: Rect::new(0.0, rule_center_y - rule * 0.5, width, rule),
1266    });
1267    translate_atoms(&mut atoms, den.atoms, den_x, den_dy);
1268    MathLayout {
1269        width,
1270        ascent,
1271        descent,
1272        atoms,
1273    }
1274}
1275
1276fn layout_sqrt(child: &MathExpr, ctx: LayoutCtx) -> MathLayout {
1277    let metrics = ctx.metrics();
1278    let inner = layout_expr(child, ctx);
1279    let gap = metrics.sqrt_gap();
1280    let rule = metrics.radical_rule_thickness();
1281    if let Some(layout) = layout_open_type_sqrt(inner.clone(), gap, rule, ctx) {
1282        return layout;
1283    }
1284    layout_vector_sqrt(inner, gap, rule, ctx)
1285}
1286
1287fn layout_vector_sqrt(inner: MathLayout, gap: f32, rule: f32, ctx: LayoutCtx) -> MathLayout {
1288    let metrics = ctx.metrics();
1289    let radical_w = metrics.radical_width();
1290    let inner_x = radical_w + gap;
1291    let bar_y = -inner.ascent - gap - rule * 0.5;
1292    let tick_y = metrics.radical_tick_y(inner.descent);
1293    let end_x = inner_x + inner.width;
1294    let mut atoms = Vec::new();
1295    atoms.push(MathAtom::Radical {
1296        points: [
1297            [0.0, metrics.radical_left_flair_y()],
1298            [metrics.radical_hook_x(), metrics.radical_hook_y()],
1299            [metrics.radical_tick_x(), tick_y],
1300            [radical_w, bar_y],
1301            [end_x, bar_y],
1302        ],
1303        thickness: rule,
1304    });
1305    translate_atoms(&mut atoms, inner.atoms, inner_x, 0.0);
1306    MathLayout {
1307        width: end_x,
1308        ascent: -bar_y + rule * 0.5,
1309        descent: tick_y + rule * 0.5,
1310        atoms,
1311    }
1312}
1313
1314fn layout_open_type_sqrt(
1315    inner: MathLayout,
1316    gap: f32,
1317    rule: f32,
1318    ctx: LayoutCtx,
1319) -> Option<MathLayout> {
1320    let metrics = ctx.metrics();
1321    let bar_y = -inner.ascent - gap - rule * 0.5;
1322    let tick_y = metrics.radical_tick_y(inner.descent);
1323    let target_height = tick_y - bar_y + rule;
1324    let variant = metrics.radical_variant_for_height(target_height)?;
1325    let bbox = variant.bbox?;
1326    let constants = metrics.font_constants()?;
1327    let scale = metrics.size / constants.units_per_em;
1328    let view_box = glyph_advance_view_box(bbox, variant.horizontal_advance, None)?;
1329    if view_box.w <= 0.0 || view_box.h <= 0.0 {
1330        return None;
1331    }
1332    let radical_w = view_box.w * scale;
1333    let radical_h = view_box.h * scale;
1334    let radical_rect = Rect::new(0.0, bar_y - rule * 0.5, radical_w, radical_h);
1335    let inner_x = radical_w + gap;
1336    let end_x = inner_x + inner.width;
1337    let overbar_x = (radical_w - rule * 0.5).max(0.0);
1338    let mut atoms = Vec::new();
1339    atoms.push(MathAtom::GlyphId {
1340        glyph_id: variant.glyph_id,
1341        rect: radical_rect,
1342        view_box,
1343    });
1344    atoms.push(MathAtom::Rule {
1345        rect: Rect::new(
1346            overbar_x,
1347            bar_y - rule * 0.5,
1348            (end_x - overbar_x).max(rule),
1349            rule,
1350        ),
1351    });
1352    translate_atoms(&mut atoms, inner.atoms, inner_x, 0.0);
1353    Some(MathLayout {
1354        width: end_x,
1355        ascent: (-bar_y + rule * 0.5).max(-radical_rect.y),
1356        descent: (tick_y + rule * 0.5).max(radical_rect.y + radical_rect.h),
1357        atoms,
1358    })
1359}
1360
1361fn layout_root(base: &MathExpr, index: &MathExpr, ctx: LayoutCtx) -> MathLayout {
1362    let metrics = ctx.metrics();
1363    let root = layout_sqrt(base, ctx);
1364    let index = layout_expr(index, ctx.script());
1365    let root_x = metrics.root_offset_x(index.width);
1366    let index_dy = metrics.root_index_shift(root.ascent, index.descent);
1367    let mut atoms = Vec::new();
1368    translate_atoms(&mut atoms, index.atoms, 0.0, index_dy);
1369    translate_atoms(&mut atoms, root.atoms, root_x, 0.0);
1370    MathLayout {
1371        width: root_x + root.width,
1372        ascent: root.ascent.max(-index_dy + index.ascent),
1373        descent: root.descent.max(index_dy + index.descent),
1374        atoms,
1375    }
1376}
1377
1378fn layout_scripts(
1379    base: &MathExpr,
1380    sub: Option<&MathExpr>,
1381    sup: Option<&MathExpr>,
1382    ctx: LayoutCtx,
1383) -> MathLayout {
1384    if matches!(ctx.display, MathDisplay::Block) && is_display_limits_base(base) {
1385        return layout_under_over(base, sub, sup, ctx);
1386    }
1387    let display_large_operator =
1388        matches!(ctx.display, MathDisplay::Block) && is_large_operator_base(base);
1389    let base_ctx = if display_large_operator {
1390        ctx.large_operator()
1391    } else {
1392        ctx
1393    };
1394    let base_layout = if display_large_operator {
1395        layout_large_operator_variant(base, base_ctx)
1396            .or_else(|| layout_operator_expr_glyph_fallback(base, base_ctx))
1397            .unwrap_or_else(|| layout_expr(base, ctx))
1398    } else {
1399        layout_expr(base, base_ctx)
1400    };
1401    let script_ctx = ctx.script();
1402    let sub_layout = sub.map(|expr| layout_expr(expr, script_ctx));
1403    let sup_layout = sup.map(|expr| layout_expr(expr, script_ctx));
1404    let metrics = ctx.metrics();
1405    let script_gap = metrics.script_gap();
1406    let script_x = base_layout.width + script_gap;
1407    let sup_dy = sup_layout
1408        .as_ref()
1409        .map(|sup| metrics.superscript_shift(base_layout.ascent, sup.descent))
1410        .unwrap_or(0.0);
1411    let mut sub_dy = sub_layout
1412        .as_ref()
1413        .map(|sub| metrics.subscript_shift(base_layout.descent, sub.ascent))
1414        .unwrap_or(0.0);
1415    if let (Some(sub), Some(sup)) = (&sub_layout, &sup_layout) {
1416        let sup_bottom = sup_dy + sup.descent;
1417        let sub_top = sub_dy - sub.ascent;
1418        let gap = sub_top - sup_bottom;
1419        let min_gap = metrics.sub_superscript_gap();
1420        if gap < min_gap {
1421            sub_dy += min_gap - gap;
1422        }
1423    }
1424    let mut atoms = Vec::new();
1425    translate_atoms(&mut atoms, base_layout.atoms, 0.0, 0.0);
1426    let mut script_width: f32 = 0.0;
1427    let mut ascent = base_layout.ascent;
1428    let mut descent = base_layout.descent;
1429    if let Some(sup) = sup_layout {
1430        script_width = script_width.max(sup.width);
1431        ascent = ascent.max(-sup_dy + sup.ascent);
1432        translate_atoms(&mut atoms, sup.atoms, script_x, sup_dy);
1433    }
1434    if let Some(sub) = sub_layout {
1435        script_width = script_width.max(sub.width);
1436        descent = descent.max(sub_dy + sub.descent);
1437        translate_atoms(&mut atoms, sub.atoms, script_x, sub_dy);
1438    }
1439    MathLayout {
1440        width: base_layout.width + script_gap + script_width,
1441        ascent,
1442        descent,
1443        atoms,
1444    }
1445}
1446
1447fn layout_under_over(
1448    base: &MathExpr,
1449    under: Option<&MathExpr>,
1450    over: Option<&MathExpr>,
1451    ctx: LayoutCtx,
1452) -> MathLayout {
1453    let center_large_operator =
1454        matches!(ctx.display, MathDisplay::Block) && is_large_operator_base(base);
1455    let base_ctx = if center_large_operator {
1456        ctx.large_operator()
1457    } else {
1458        ctx
1459    };
1460    let base_layout = if center_large_operator {
1461        layout_large_operator_variant(base, base_ctx)
1462            .or_else(|| layout_operator_expr_glyph_fallback(base, base_ctx))
1463            .unwrap_or_else(|| layout_expr(base, ctx))
1464    } else {
1465        layout_expr(base, base_ctx)
1466    };
1467    let script_ctx = ctx.script();
1468    let under_layout = under.map(|expr| layout_expr(expr, script_ctx));
1469    let over_layout = over.map(|expr| layout_expr(expr, script_ctx));
1470    let metrics = ctx.metrics();
1471    let width = base_layout
1472        .width
1473        .max(under_layout.as_ref().map(|l| l.width).unwrap_or(0.0))
1474        .max(over_layout.as_ref().map(|l| l.width).unwrap_or(0.0));
1475    let base_x = (width - base_layout.width) * 0.5;
1476    let base_dy = if center_large_operator {
1477        base_ctx.metrics().math_axis_shift() - ctx.metrics().math_axis_shift()
1478    } else {
1479        0.0
1480    };
1481    let base_top = -base_layout.ascent + base_dy;
1482    let base_bottom = base_layout.descent + base_dy;
1483    let mut atoms = Vec::new();
1484    let mut ascent = -base_top;
1485    let mut descent = base_bottom;
1486    translate_atoms(&mut atoms, base_layout.atoms, base_x, base_dy);
1487    if let Some(over) = over_layout {
1488        let over_x = (width - over.width) * 0.5;
1489        let over_dy = (base_top - metrics.upper_limit_gap() - over.descent)
1490            .min(base_dy - metrics.upper_limit_baseline_rise());
1491        ascent = ascent.max(-over_dy + over.ascent);
1492        translate_atoms(&mut atoms, over.atoms, over_x, over_dy);
1493    }
1494    if let Some(under) = under_layout {
1495        let under_x = (width - under.width) * 0.5;
1496        let under_dy = (base_bottom + metrics.lower_limit_gap() + under.ascent)
1497            .max(base_dy + metrics.lower_limit_baseline_drop());
1498        descent = descent.max(under_dy + under.descent);
1499        translate_atoms(&mut atoms, under.atoms, under_x, under_dy);
1500    }
1501    MathLayout {
1502        width,
1503        ascent,
1504        descent,
1505        atoms,
1506    }
1507}
1508
1509fn layout_accent(base: &MathExpr, accent: &MathExpr, stretch: bool, ctx: LayoutCtx) -> MathLayout {
1510    let base_layout = layout_expr(base, ctx);
1511    if stretch && is_overline_accent(accent) {
1512        return layout_overline(base_layout, ctx);
1513    }
1514
1515    let accent_layout = layout_accent_mark(accent, ctx.script());
1516    let metrics = ctx.metrics();
1517    let gap = metrics.accent_gap();
1518    let width = base_layout.width.max(accent_layout.width);
1519    let base_x = (width - base_layout.width) * 0.5;
1520    let accent_x = (width - accent_layout.width) * 0.5;
1521    let accent_dy = -base_layout.ascent - gap - accent_layout.descent;
1522    let mut atoms = Vec::new();
1523    translate_atoms(&mut atoms, base_layout.atoms, base_x, 0.0);
1524    translate_atoms(&mut atoms, accent_layout.atoms, accent_x, accent_dy);
1525    MathLayout {
1526        width,
1527        ascent: base_layout.ascent.max(-accent_dy + accent_layout.ascent),
1528        descent: base_layout.descent,
1529        atoms,
1530    }
1531}
1532
1533fn layout_overline(base_layout: MathLayout, ctx: LayoutCtx) -> MathLayout {
1534    let metrics = ctx.metrics();
1535    let rule = metrics.rule_thickness();
1536    let gap = metrics.accent_gap();
1537    let rule_y = -base_layout.ascent - gap - rule;
1538    let mut atoms = Vec::new();
1539    translate_atoms(&mut atoms, base_layout.atoms, 0.0, 0.0);
1540    atoms.push(MathAtom::Rule {
1541        rect: Rect::new(0.0, rule_y, base_layout.width.max(rule), rule),
1542    });
1543    MathLayout {
1544        width: base_layout.width,
1545        ascent: (-rule_y).max(base_layout.ascent),
1546        descent: base_layout.descent,
1547        atoms,
1548    }
1549}
1550
1551fn is_overline_accent(expr: &MathExpr) -> bool {
1552    matches!(
1553        expr.without_source(),
1554        MathExpr::Operator(s) | MathExpr::Text(s) | MathExpr::Identifier(s)
1555            if matches!(s.as_str(), "¯" | "‾")
1556    )
1557}
1558
1559fn layout_accent_mark(accent: &MathExpr, ctx: LayoutCtx) -> MathLayout {
1560    match accent.without_source() {
1561        MathExpr::Operator(s) if s == "^" => layout_operator("ˆ", ctx),
1562        MathExpr::Operator(s) if s == "~" => layout_operator("˜", ctx),
1563        _ => layout_expr(accent, ctx),
1564    }
1565}
1566
1567fn is_display_limits_base(expr: &MathExpr) -> bool {
1568    match expr.without_source() {
1569        MathExpr::Operator(_) | MathExpr::OperatorWithMetadata { .. } => has_movable_limits(expr),
1570        MathExpr::Text(s) => matches!(
1571            s.as_str(),
1572            "lim" | "max" | "min" | "sup" | "inf" | "det" | "gcd" | "Pr" | "lim inf" | "lim sup"
1573        ),
1574        _ => false,
1575    }
1576}
1577
1578fn has_movable_limits(expr: &MathExpr) -> bool {
1579    match expr.without_source() {
1580        MathExpr::Operator(s) => operator_info(s).movable_limits,
1581        MathExpr::OperatorWithMetadata {
1582            text,
1583            movable_limits,
1584            ..
1585        } => movable_limits.unwrap_or_else(|| operator_info(text).movable_limits),
1586        _ => false,
1587    }
1588}
1589
1590fn is_large_operator_base(expr: &MathExpr) -> bool {
1591    match expr.without_source() {
1592        MathExpr::Operator(s) => is_large_operator_symbol_str(s),
1593        MathExpr::OperatorWithMetadata {
1594            text,
1595            large_operator,
1596            ..
1597        } => large_operator.unwrap_or_else(|| operator_info(text).large_operator),
1598        _ => false,
1599    }
1600}
1601
1602fn is_large_operator_symbol_str(s: &str) -> bool {
1603    operator_info(s).large_operator
1604}
1605
1606fn is_large_operator_symbol(ch: char) -> bool {
1607    operator_info(&ch.to_string()).large_operator
1608}
1609
1610fn layout_large_operator_variant(expr: &MathExpr, ctx: LayoutCtx) -> Option<MathLayout> {
1611    let (operator, lspace_override, rspace_override) = match expr.without_source() {
1612        MathExpr::Operator(operator) => (operator.as_str(), None, None),
1613        MathExpr::OperatorWithMetadata {
1614            text,
1615            lspace,
1616            rspace,
1617            ..
1618        } => (text.as_str(), *lspace, *rspace),
1619        _ => return None,
1620    };
1621    let metrics = ctx.metrics();
1622    let variant = metrics.large_operator_variant_for_height(operator, ctx.size)?;
1623    let bbox = variant.bbox?;
1624    let constants = metrics.font_constants()?;
1625    let scale = metrics.size / constants.units_per_em;
1626    let view_box = glyph_advance_view_box(bbox, variant.horizontal_advance, None)?;
1627    let glyph_width = view_box.w * scale;
1628    let glyph_height = view_box.h * scale;
1629    if glyph_width <= 0.0 || glyph_height <= 0.0 {
1630        return None;
1631    }
1632    let width = (variant.horizontal_advance as f32 * scale).max(glyph_width);
1633    let target_center_y = -metrics.math_axis_shift();
1634    let glyph_center_y = view_box.y * scale + glyph_height * 0.5;
1635    let glyph_y = target_center_y - glyph_center_y;
1636    let (lspace, rspace) =
1637        metrics.operator_spacing_with_overrides(operator, lspace_override, rspace_override);
1638    let rect = Rect::new(
1639        lspace + (width - glyph_width) * 0.5,
1640        glyph_y + view_box.y * scale,
1641        glyph_width,
1642        glyph_height,
1643    );
1644    Some(MathLayout {
1645        width: width + lspace + rspace,
1646        ascent: -rect.y,
1647        descent: rect.y + rect.h,
1648        atoms: vec![MathAtom::GlyphId {
1649            glyph_id: variant.glyph_id,
1650            rect,
1651            view_box,
1652        }],
1653    })
1654}
1655
1656fn single_char(s: &str) -> Option<char> {
1657    let mut chars = s.chars();
1658    let ch = chars.next()?;
1659    chars.next().is_none().then_some(ch)
1660}
1661
1662fn layout_fenced(
1663    open: &Option<String>,
1664    close: &Option<String>,
1665    body: &MathExpr,
1666    ctx: LayoutCtx,
1667) -> MathLayout {
1668    let body_layout = layout_expr(body, ctx);
1669    let delimiter_rect = delimiter_rect(&body_layout, ctx);
1670    let metrics = ctx.metrics();
1671    let gap = metrics.delimiter_gap();
1672    let stretch_delimiters = metrics.should_stretch_delimiter(&body_layout);
1673    let open_layout = open
1674        .as_deref()
1675        .map(|delimiter| layout_delimiter(delimiter, delimiter_rect, stretch_delimiters, ctx));
1676    let close_layout = close
1677        .as_deref()
1678        .map(|delimiter| layout_delimiter(delimiter, delimiter_rect, stretch_delimiters, ctx));
1679    let open_width = open_layout
1680        .as_ref()
1681        .map(|layout| layout.width + gap)
1682        .unwrap_or(0.0);
1683    let close_width = close_layout
1684        .as_ref()
1685        .map(|layout| layout.width + gap)
1686        .unwrap_or(0.0);
1687    let delimiter_ascent = open_layout
1688        .as_ref()
1689        .into_iter()
1690        .chain(close_layout.as_ref())
1691        .map(|layout| layout.ascent)
1692        .fold(0.0, f32::max);
1693    let delimiter_descent = open_layout
1694        .as_ref()
1695        .into_iter()
1696        .chain(close_layout.as_ref())
1697        .map(|layout| layout.descent)
1698        .fold(0.0, f32::max);
1699    let mut atoms = Vec::new();
1700    if let Some(open) = open_layout {
1701        translate_atoms(&mut atoms, open.atoms, 0.0, 0.0);
1702    }
1703    translate_atoms(&mut atoms, body_layout.atoms, open_width, 0.0);
1704    if let Some(close) = close_layout {
1705        translate_atoms(
1706            &mut atoms,
1707            close.atoms,
1708            open_width + body_layout.width + gap,
1709            0.0,
1710        );
1711    }
1712    MathLayout {
1713        width: open_width + body_layout.width + close_width,
1714        ascent: body_layout.ascent.max(delimiter_ascent),
1715        descent: body_layout.descent.max(delimiter_descent),
1716        atoms,
1717    }
1718}
1719
1720fn delimiter_rect(body: &MathLayout, ctx: LayoutCtx) -> Rect {
1721    let metrics = ctx.metrics();
1722    let overshoot = metrics.delimiter_overshoot();
1723    let top = -body.ascent - overshoot;
1724    let bottom = body.descent + overshoot;
1725    Rect::new(0.0, top, metrics.delimiter_width(), bottom - top)
1726}
1727
1728fn layout_delimiter(delimiter: &str, rect: Rect, stretch: bool, ctx: LayoutCtx) -> MathLayout {
1729    if !stretch || !is_vector_delimiter(delimiter) {
1730        return layout_glyph(delimiter, ctx, FontWeight::Regular, false);
1731    }
1732    if let Some(delimiter) = delimiter
1733        .chars()
1734        .next()
1735        .filter(|_| delimiter.chars().count() == 1)
1736        && let Some(variant) = ctx
1737            .metrics()
1738            .delimiter_variant_for_height(delimiter, rect.h)
1739        && let Some(layout) = layout_delimiter_variant(variant, rect, ctx)
1740    {
1741        return layout;
1742    }
1743    if let Some(delimiter) = delimiter
1744        .chars()
1745        .next()
1746        .filter(|_| delimiter.chars().count() == 1)
1747        && let Some(parts) = ctx.metrics().delimiter_assembly_parts(delimiter)
1748        && let Some(layout) = layout_delimiter_assembly(&parts, rect, ctx)
1749    {
1750        return layout;
1751    }
1752    MathLayout {
1753        width: rect.w,
1754        ascent: -rect.y,
1755        descent: rect.y + rect.h,
1756        atoms: vec![MathAtom::Delimiter {
1757            delimiter: delimiter.to_string(),
1758            rect,
1759            thickness: ctx.metrics().rule_thickness(),
1760        }],
1761    }
1762}
1763
1764fn is_vector_delimiter(delimiter: &str) -> bool {
1765    matches!(
1766        delimiter,
1767        "(" | ")" | "[" | "]" | "{" | "}" | "|" | "‖" | "⟨" | "⟩" | "⌊" | "⌋" | "⌈" | "⌉"
1768    )
1769}
1770
1771fn layout_delimiter_variant(
1772    variant: OpenTypeDelimiterVariant,
1773    target_rect: Rect,
1774    ctx: LayoutCtx,
1775) -> Option<MathLayout> {
1776    let bbox = variant.bbox?;
1777    let metrics = ctx.metrics();
1778    let constants = metrics.font_constants()?;
1779    let scale = metrics.size / constants.units_per_em;
1780    let width = (variant.horizontal_advance as f32 * scale).max(target_rect.w);
1781    let view_box = glyph_advance_view_box(bbox, variant.horizontal_advance, None)?;
1782    let glyph_height = view_box.h * scale;
1783    if view_box.w <= 0.0 || glyph_height <= 0.0 {
1784        return None;
1785    }
1786    let target_center_y = target_rect.y + target_rect.h * 0.5;
1787    let glyph_center_y = view_box.y * scale + glyph_height * 0.5;
1788    let glyph_y = target_center_y - glyph_center_y;
1789    let rect = Rect::new(
1790        (width - view_box.w * scale) * 0.5,
1791        glyph_y + view_box.y * scale,
1792        view_box.w * scale,
1793        glyph_height,
1794    );
1795    Some(MathLayout {
1796        width,
1797        ascent: (-rect.y).max(-target_rect.y),
1798        descent: (rect.y + rect.h).max(target_rect.y + target_rect.h),
1799        atoms: vec![MathAtom::GlyphId {
1800            glyph_id: variant.glyph_id,
1801            rect,
1802            view_box,
1803        }],
1804    })
1805}
1806
1807fn layout_delimiter_assembly(
1808    parts: &[OpenTypeDelimiterAssemblyPart],
1809    target_rect: Rect,
1810    ctx: LayoutCtx,
1811) -> Option<MathLayout> {
1812    let metrics = ctx.metrics();
1813    let constants = metrics.font_constants()?;
1814    if constants.units_per_em <= 0.0 {
1815        return None;
1816    }
1817    let scale = metrics.size / constants.units_per_em;
1818    let overlap_units = constants.min_connector_overlap.max(1);
1819    let target_units = target_rect.h / scale;
1820    let source_parts: Vec<OpenTypeDelimiterAssemblyPart> = parts.iter().rev().copied().collect();
1821    let mut assembly = source_parts.clone();
1822    let extender_parts: Vec<OpenTypeDelimiterAssemblyPart> = source_parts
1823        .iter()
1824        .copied()
1825        .filter(|part| part.extender)
1826        .collect();
1827    if extender_parts.is_empty() {
1828        return None;
1829    }
1830
1831    let mut extra_repeats = 0;
1832    while assembly_max_length_units(&assembly, overlap_units) < target_units {
1833        extra_repeats += 1;
1834        assembly = Vec::with_capacity(source_parts.len() + extra_repeats * extender_parts.len());
1835        for part in &source_parts {
1836            assembly.push(*part);
1837            if part.extender {
1838                assembly.extend(std::iter::repeat_n(*part, extra_repeats));
1839            }
1840        }
1841    }
1842
1843    let overlaps = assembly_overlaps_for_target(&assembly, target_units, overlap_units);
1844    let total_units = assembly_raw_advance_units(&assembly) - overlaps.iter().sum::<f32>();
1845    let total_height = total_units * scale;
1846    let target_center_y = target_rect.y + target_rect.h * 0.5;
1847    let top = target_center_y - total_height * 0.5;
1848    let width = assembly
1849        .iter()
1850        .filter_map(|part| {
1851            let bbox = part.bbox?;
1852            Some(
1853                (part.horizontal_advance as f32 * scale)
1854                    .max((bbox.x_max - bbox.x_min) as f32 * scale),
1855            )
1856        })
1857        .fold(target_rect.w, f32::max);
1858
1859    let mut cursor_units = 0.0;
1860    let mut atoms = Vec::with_capacity(assembly.len());
1861    for (index, part) in assembly.iter().enumerate() {
1862        let bbox = part.bbox?;
1863        let slot_height = part.full_advance as f32 * scale;
1864        let view_box =
1865            glyph_advance_view_box(bbox, part.horizontal_advance, Some(part.full_advance))?;
1866        let glyph_width = view_box.w * scale;
1867        let glyph_height = view_box.h * scale;
1868        if glyph_width <= 0.0 || glyph_height <= 0.0 || slot_height <= 0.0 {
1869            return None;
1870        }
1871        let rect = Rect::new(
1872            (width - glyph_width) * 0.5,
1873            top + cursor_units * scale,
1874            glyph_width,
1875            slot_height.max(glyph_height),
1876        );
1877        atoms.push(MathAtom::GlyphId {
1878            glyph_id: part.glyph_id,
1879            rect,
1880            view_box,
1881        });
1882        if index + 1 < assembly.len() {
1883            cursor_units += part.full_advance as f32 - overlaps[index];
1884        }
1885    }
1886
1887    Some(MathLayout {
1888        width,
1889        ascent: (-top).max(-target_rect.y),
1890        descent: (top + total_height).max(target_rect.y + target_rect.h),
1891        atoms,
1892    })
1893}
1894
1895fn glyph_advance_view_box(
1896    bbox: OpenTypeGlyphBBox,
1897    horizontal_advance: u16,
1898    vertical_advance: Option<u16>,
1899) -> Option<Rect> {
1900    let x = (bbox.x_min as f32).min(0.0);
1901    let width = (horizontal_advance as f32)
1902        .max(bbox.x_max as f32 - x)
1903        .max((bbox.x_max - bbox.x_min) as f32);
1904    let y = -(bbox.y_max as f32);
1905    let height = vertical_advance
1906        .map(f32::from)
1907        .unwrap_or((bbox.y_max - bbox.y_min) as f32)
1908        .max((bbox.y_max - bbox.y_min) as f32);
1909    (width > 0.0 && height > 0.0).then(|| Rect::new(x, y, width, height))
1910}
1911
1912fn assembly_raw_advance_units(parts: &[OpenTypeDelimiterAssemblyPart]) -> f32 {
1913    parts.iter().map(|part| part.full_advance as f32).sum()
1914}
1915
1916fn assembly_max_length_units(parts: &[OpenTypeDelimiterAssemblyPart], min_overlap: u16) -> f32 {
1917    assembly_raw_advance_units(parts)
1918        - assembly_overlap_limits(parts, min_overlap)
1919            .iter()
1920            .map(|(min, _)| *min)
1921            .sum::<f32>()
1922}
1923
1924fn assembly_overlap_limits(
1925    parts: &[OpenTypeDelimiterAssemblyPart],
1926    min_overlap: u16,
1927) -> Vec<(f32, f32)> {
1928    parts
1929        .windows(2)
1930        .map(|pair| {
1931            let min = min_overlap as f32;
1932            let max = pair[0]
1933                .end_connector_length
1934                .min(pair[1].start_connector_length)
1935                .max(min_overlap) as f32;
1936            (min, max)
1937        })
1938        .collect()
1939}
1940
1941fn assembly_overlaps_for_target(
1942    parts: &[OpenTypeDelimiterAssemblyPart],
1943    target_units: f32,
1944    min_overlap: u16,
1945) -> Vec<f32> {
1946    let limits = assembly_overlap_limits(parts, min_overlap);
1947    if limits.is_empty() {
1948        return Vec::new();
1949    }
1950    let raw = assembly_raw_advance_units(parts);
1951    let min_sum: f32 = limits.iter().map(|(min, _)| *min).sum();
1952    let max_sum: f32 = limits.iter().map(|(_, max)| *max).sum();
1953    let desired_sum = (raw - target_units).clamp(min_sum, max_sum);
1954    let mut overlaps: Vec<f32> = limits.iter().map(|(min, _)| *min).collect();
1955    let mut remaining = desired_sum - min_sum;
1956
1957    while remaining > 0.001 {
1958        let adjustable: Vec<usize> = overlaps
1959            .iter()
1960            .zip(limits.iter())
1961            .enumerate()
1962            .filter_map(|(index, (overlap, (_, max)))| (*overlap < *max - 0.001).then_some(index))
1963            .collect();
1964        if adjustable.is_empty() {
1965            break;
1966        }
1967        let share = remaining / adjustable.len() as f32;
1968        let mut distributed = 0.0;
1969        for index in adjustable {
1970            let capacity = limits[index].1 - overlaps[index];
1971            let add = share.min(capacity);
1972            overlaps[index] += add;
1973            distributed += add;
1974        }
1975        if distributed <= 0.001 {
1976            break;
1977        }
1978        remaining -= distributed;
1979    }
1980
1981    overlaps
1982}
1983
1984fn layout_table(
1985    rows: &[Vec<MathExpr>],
1986    column_alignments: &[MathColumnAlignment],
1987    column_gap: Option<f32>,
1988    row_gap: Option<f32>,
1989    ctx: LayoutCtx,
1990) -> MathLayout {
1991    if rows.is_empty() {
1992        return MathLayout {
1993            width: 0.0,
1994            ascent: 0.0,
1995            descent: 0.0,
1996            atoms: Vec::new(),
1997        };
1998    }
1999    let cell_layouts: Vec<Vec<MathLayout>> = rows
2000        .iter()
2001        .map(|row| row.iter().map(|cell| layout_expr(cell, ctx)).collect())
2002        .collect();
2003    let metrics = ctx.metrics();
2004    let col_count = cell_layouts.iter().map(Vec::len).max().unwrap_or(0);
2005    let mut col_widths = vec![0.0_f32; col_count];
2006    let mut row_ascents = vec![metrics.default_ascent(); rows.len()];
2007    let mut row_descents = vec![metrics.default_descent(); rows.len()];
2008    for (row_index, row) in cell_layouts.iter().enumerate() {
2009        for (col_index, cell) in row.iter().enumerate() {
2010            col_widths[col_index] = col_widths[col_index].max(cell.width);
2011            row_ascents[row_index] = row_ascents[row_index].max(cell.ascent);
2012            row_descents[row_index] = row_descents[row_index].max(cell.descent);
2013        }
2014    }
2015    let col_gap = metrics.table_col_gap(column_gap);
2016    let row_gap = metrics.table_row_gap(row_gap);
2017    let width = col_widths.iter().sum::<f32>() + col_gap * col_count.saturating_sub(1) as f32;
2018    let row_heights: Vec<f32> = row_ascents
2019        .iter()
2020        .zip(row_descents.iter())
2021        .map(|(ascent, descent)| ascent + descent)
2022        .collect();
2023    let height = row_heights.iter().sum::<f32>() + row_gap * rows.len().saturating_sub(1) as f32;
2024    let baseline_origin = height * 0.5 + metrics.math_axis_shift();
2025    let mut atoms = Vec::new();
2026    let mut row_top = 0.0;
2027    for (row_index, row) in cell_layouts.into_iter().enumerate() {
2028        let row_baseline = row_top + row_ascents[row_index];
2029        let mut col_left = 0.0;
2030        for (col_index, cell) in row.into_iter().enumerate() {
2031            let col_extra = col_widths[col_index] - cell.width;
2032            let align = column_alignments
2033                .get(col_index)
2034                .copied()
2035                .unwrap_or_default();
2036            let cell_x = col_left
2037                + match align {
2038                    MathColumnAlignment::Left => 0.0,
2039                    MathColumnAlignment::Center => col_extra * 0.5,
2040                    MathColumnAlignment::Right => col_extra,
2041                };
2042            translate_atoms(
2043                &mut atoms,
2044                cell.atoms,
2045                cell_x,
2046                row_baseline - baseline_origin,
2047            );
2048            col_left += col_widths[col_index] + col_gap;
2049        }
2050        row_top += row_heights[row_index] + row_gap;
2051    }
2052    MathLayout {
2053        width,
2054        ascent: baseline_origin,
2055        descent: height - baseline_origin,
2056        atoms,
2057    }
2058}
2059
2060fn translate_atoms(out: &mut Vec<MathAtom>, atoms: Vec<MathAtom>, dx: f32, dy: f32) {
2061    out.extend(atoms.into_iter().map(|atom| match atom {
2062        MathAtom::Glyph {
2063            text,
2064            x,
2065            y_baseline,
2066            size,
2067            weight,
2068            italic,
2069        } => MathAtom::Glyph {
2070            text,
2071            x: x + dx,
2072            y_baseline: y_baseline + dy,
2073            size,
2074            weight,
2075            italic,
2076        },
2077        MathAtom::GlyphId {
2078            glyph_id,
2079            rect,
2080            view_box,
2081        } => MathAtom::GlyphId {
2082            glyph_id,
2083            rect: Rect::new(rect.x + dx, rect.y + dy, rect.w, rect.h),
2084            view_box,
2085        },
2086        MathAtom::Rule { rect } => MathAtom::Rule {
2087            rect: Rect::new(rect.x + dx, rect.y + dy, rect.w, rect.h),
2088        },
2089        MathAtom::Radical { points, thickness } => MathAtom::Radical {
2090            points: points.map(|[x, y]| [x + dx, y + dy]),
2091            thickness,
2092        },
2093        MathAtom::Delimiter {
2094            delimiter,
2095            rect,
2096            thickness,
2097        } => MathAtom::Delimiter {
2098            delimiter,
2099            rect: Rect::new(rect.x + dx, rect.y + dy, rect.w, rect.h),
2100            thickness,
2101        },
2102    }));
2103}
2104
2105pub fn parse_tex(input: &str) -> Result<MathExpr, MathParseError> {
2106    let mut parser = TexParser::new(input);
2107    let expr = parser.parse_row(None)?;
2108    parser.skip_ws();
2109    if parser.peek().is_some() {
2110        return Err(parser.error("unexpected trailing input"));
2111    }
2112    Ok(expr)
2113}
2114
2115pub fn parse_tex_with_source_ranges(input: &str) -> Result<MathExpr, MathParseError> {
2116    let mut parser = TexParser::with_source_ranges(input);
2117    let expr = parser.parse_row(None)?;
2118    parser.skip_ws();
2119    if parser.peek().is_some() {
2120        return Err(parser.error("unexpected trailing input"));
2121    }
2122    Ok(expr)
2123}
2124
2125pub fn parse_mathml(input: &str) -> Result<MathExpr, MathParseError> {
2126    Ok(parse_mathml_with_display(input)?.0)
2127}
2128
2129pub fn parse_mathml_with_display(input: &str) -> Result<(MathExpr, MathDisplay), MathParseError> {
2130    let doc = roxmltree::Document::parse(input).map_err(|err| {
2131        let pos = err.pos();
2132        MathParseError {
2133            message: err.to_string(),
2134            byte: text_pos_to_byte(input, pos.row, pos.col),
2135        }
2136    })?;
2137    let root = doc.root_element();
2138    let display = match root.attribute("display") {
2139        Some("block") => MathDisplay::Block,
2140        _ => MathDisplay::Inline,
2141    };
2142    let expr = parse_mathml_node(root)?;
2143    Ok((expr, display))
2144}
2145
2146#[derive(Clone, Debug, PartialEq, Eq)]
2147pub struct MathParseError {
2148    pub message: String,
2149    pub byte: usize,
2150}
2151
2152fn parse_mathml_node(node: roxmltree::Node<'_, '_>) -> Result<MathExpr, MathParseError> {
2153    let name = node.tag_name().name();
2154    match name {
2155        "math" | "mrow" => Ok(MathExpr::row(parse_mathml_children(node)?)),
2156        "mi" => Ok(MathExpr::Identifier(normalized_node_text(node))),
2157        "mn" => Ok(MathExpr::Number(normalized_node_text(node))),
2158        "mo" => parse_mathml_operator(node),
2159        "mtext" => Ok(MathExpr::Text(normalized_node_text(node))),
2160        "mspace" => Ok(MathExpr::Space(parse_mathml_space(node))),
2161        "mfrac" => {
2162            let children = mathml_element_children(node);
2163            require_mathml_arity(node, &children, 2)?;
2164            Ok(MathExpr::Fraction {
2165                numerator: Arc::new(parse_mathml_node(children[0])?),
2166                denominator: Arc::new(parse_mathml_node(children[1])?),
2167            })
2168        }
2169        "msqrt" => Ok(MathExpr::Sqrt(Arc::new(MathExpr::row(
2170            parse_mathml_children(node)?,
2171        )))),
2172        "mroot" => {
2173            let children = mathml_element_children(node);
2174            require_mathml_arity(node, &children, 2)?;
2175            Ok(MathExpr::Root {
2176                base: Arc::new(parse_mathml_node(children[0])?),
2177                index: Arc::new(parse_mathml_node(children[1])?),
2178            })
2179        }
2180        "msub" => parse_mathml_scripts(node, true, false),
2181        "msup" => parse_mathml_scripts(node, false, true),
2182        "msubsup" => parse_mathml_scripts(node, true, true),
2183        "munder" => parse_mathml_under_over(node, true, false),
2184        "mover" if mathml_bool_attr(node.attribute("accent")) => parse_mathml_accent(node),
2185        "mover" => parse_mathml_under_over(node, false, true),
2186        "munderover" => parse_mathml_under_over(node, true, true),
2187        "mfenced" => parse_mathml_fenced(node),
2188        "semantics" => parse_mathml_semantics(node),
2189        "mtable" => parse_mathml_table(node),
2190        "mtr" => Ok(MathExpr::row(
2191            mathml_element_children(node)
2192                .into_iter()
2193                .map(parse_mathml_node)
2194                .collect::<Result<Vec<_>, _>>()?,
2195        )),
2196        "mtd" => Ok(MathExpr::row(parse_mathml_children(node)?)),
2197        unsupported => Ok(MathExpr::Error(format!(
2198            "unsupported MathML element <{unsupported}>"
2199        ))),
2200    }
2201}
2202
2203fn parse_mathml_children(node: roxmltree::Node<'_, '_>) -> Result<Vec<MathExpr>, MathParseError> {
2204    mathml_element_children(node)
2205        .into_iter()
2206        .map(parse_mathml_node)
2207        .collect()
2208}
2209
2210fn parse_mathml_operator(node: roxmltree::Node<'_, '_>) -> Result<MathExpr, MathParseError> {
2211    let operator = normalized_node_text(node);
2212    let lspace = node.attribute("lspace").and_then(parse_em_length);
2213    let rspace = node.attribute("rspace").and_then(parse_em_length);
2214    let large_operator = node.attribute("largeop").map(mathml_bool_attr_value);
2215    let movable_limits = node.attribute("movablelimits").map(mathml_bool_attr_value);
2216    if lspace.is_none() && rspace.is_none() && large_operator.is_none() && movable_limits.is_none()
2217    {
2218        return Ok(MathExpr::Operator(operator));
2219    }
2220    Ok(MathExpr::OperatorWithMetadata {
2221        text: operator,
2222        lspace,
2223        rspace,
2224        large_operator,
2225        movable_limits,
2226    })
2227}
2228
2229fn parse_mathml_semantics(node: roxmltree::Node<'_, '_>) -> Result<MathExpr, MathParseError> {
2230    let children = mathml_element_children(node);
2231    let Some(presentation) = children
2232        .into_iter()
2233        .find(|child| !matches!(child.tag_name().name(), "annotation" | "annotation-xml"))
2234    else {
2235        return Err(mathml_error_at(
2236            node,
2237            "<semantics> expected a presentation child".to_string(),
2238        ));
2239    };
2240    parse_mathml_node(presentation)
2241}
2242
2243fn mathml_element_children<'a, 'input>(
2244    node: roxmltree::Node<'a, 'input>,
2245) -> Vec<roxmltree::Node<'a, 'input>> {
2246    node.children()
2247        .filter(roxmltree::Node::is_element)
2248        .collect()
2249}
2250
2251fn require_mathml_arity(
2252    node: roxmltree::Node<'_, '_>,
2253    children: &[roxmltree::Node<'_, '_>],
2254    expected: usize,
2255) -> Result<(), MathParseError> {
2256    if children.len() == expected {
2257        Ok(())
2258    } else {
2259        Err(mathml_error_at(
2260            node,
2261            format!(
2262                "<{}> expected {expected} element children, got {}",
2263                node.tag_name().name(),
2264                children.len()
2265            ),
2266        ))
2267    }
2268}
2269
2270fn parse_mathml_scripts(
2271    node: roxmltree::Node<'_, '_>,
2272    has_sub: bool,
2273    has_sup: bool,
2274) -> Result<MathExpr, MathParseError> {
2275    let children = mathml_element_children(node);
2276    let expected = 1 + usize::from(has_sub) + usize::from(has_sup);
2277    require_mathml_arity(node, &children, expected)?;
2278    let base = Arc::new(parse_mathml_node(children[0])?);
2279    let sub = has_sub.then(|| {
2280        let index = 1;
2281        parse_mathml_node(children[index]).map(Arc::new)
2282    });
2283    let sup = has_sup.then(|| {
2284        let index = if has_sub { 2 } else { 1 };
2285        parse_mathml_node(children[index]).map(Arc::new)
2286    });
2287    Ok(MathExpr::Scripts {
2288        base,
2289        sub: sub.transpose()?,
2290        sup: sup.transpose()?,
2291    })
2292}
2293
2294fn parse_mathml_under_over(
2295    node: roxmltree::Node<'_, '_>,
2296    has_under: bool,
2297    has_over: bool,
2298) -> Result<MathExpr, MathParseError> {
2299    let children = mathml_element_children(node);
2300    let expected = 1 + usize::from(has_under) + usize::from(has_over);
2301    require_mathml_arity(node, &children, expected)?;
2302    let base = Arc::new(parse_mathml_node(children[0])?);
2303    let under = has_under.then(|| {
2304        let index = 1;
2305        parse_mathml_node(children[index]).map(Arc::new)
2306    });
2307    let over = has_over.then(|| {
2308        let index = if has_under { 2 } else { 1 };
2309        parse_mathml_node(children[index]).map(Arc::new)
2310    });
2311    Ok(MathExpr::UnderOver {
2312        base,
2313        under: under.transpose()?,
2314        over: over.transpose()?,
2315    })
2316}
2317
2318fn parse_mathml_accent(node: roxmltree::Node<'_, '_>) -> Result<MathExpr, MathParseError> {
2319    let children = mathml_element_children(node);
2320    require_mathml_arity(node, &children, 2)?;
2321    let accent = parse_mathml_node(children[1])?;
2322    let stretch =
2323        mathml_bool_attr(children[1].attribute("stretchy")) || is_overline_accent(&accent);
2324    Ok(MathExpr::Accent {
2325        base: Arc::new(parse_mathml_node(children[0])?),
2326        accent: Arc::new(accent),
2327        stretch,
2328    })
2329}
2330
2331fn mathml_bool_attr(value: Option<&str>) -> bool {
2332    value.is_some_and(mathml_bool_attr_value)
2333}
2334
2335fn mathml_bool_attr_value(value: &str) -> bool {
2336    matches!(value.trim(), "true" | "1")
2337}
2338
2339fn parse_mathml_table(node: roxmltree::Node<'_, '_>) -> Result<MathExpr, MathParseError> {
2340    let mut rows = Vec::new();
2341    for row_node in mathml_element_children(node) {
2342        if !matches!(row_node.tag_name().name(), "mtr" | "mlabeledtr") {
2343            return Err(mathml_error_at(
2344                row_node,
2345                format!(
2346                    "<mtable> expected row element children, got <{}>",
2347                    row_node.tag_name().name()
2348                ),
2349            ));
2350        }
2351        let mut row = Vec::new();
2352        for cell_node in mathml_element_children(row_node) {
2353            require_mathml_tag(cell_node, "mtd")?;
2354            row.push(MathExpr::row(parse_mathml_children(cell_node)?));
2355        }
2356        rows.push(row);
2357    }
2358    let column_alignments = parse_mathml_column_alignments(node.attribute("columnalign"))?;
2359    let column_gap = parse_mathml_table_spacing(node.attribute("columnspacing"))?;
2360    let row_gap = parse_mathml_table_spacing(node.attribute("rowspacing"))?;
2361    Ok(MathExpr::Table {
2362        rows,
2363        column_alignments,
2364        column_gap,
2365        row_gap,
2366    })
2367}
2368
2369fn parse_mathml_column_alignments(
2370    value: Option<&str>,
2371) -> Result<Vec<MathColumnAlignment>, MathParseError> {
2372    let Some(value) = value else {
2373        return Ok(Vec::new());
2374    };
2375    value
2376        .split_whitespace()
2377        .map(|token| match token {
2378            "left" => Ok(MathColumnAlignment::Left),
2379            "center" => Ok(MathColumnAlignment::Center),
2380            "right" => Ok(MathColumnAlignment::Right),
2381            "decimal" => Ok(MathColumnAlignment::Right),
2382            other => Err(MathParseError {
2383                message: format!("unsupported MathML columnalign value {other:?}"),
2384                byte: 0,
2385            }),
2386        })
2387        .collect()
2388}
2389
2390fn parse_mathml_table_spacing(value: Option<&str>) -> Result<Option<f32>, MathParseError> {
2391    let Some(value) = value else {
2392        return Ok(None);
2393    };
2394    let Some(first) = value.split_whitespace().next() else {
2395        return Ok(None);
2396    };
2397    parse_mathml_em_length(first).map(Some)
2398}
2399
2400fn parse_mathml_em_length(value: &str) -> Result<f32, MathParseError> {
2401    let number = value.strip_suffix("em").unwrap_or(value);
2402    let parsed = number.parse::<f32>().map_err(|_| MathParseError {
2403        message: format!("unsupported MathML table spacing value {value:?}"),
2404        byte: 0,
2405    })?;
2406    if parsed.is_sign_negative() {
2407        return Err(MathParseError {
2408            message: format!("negative MathML table spacing value {value:?}"),
2409            byte: 0,
2410        });
2411    }
2412    Ok(parsed)
2413}
2414
2415fn parse_mathml_fenced(node: roxmltree::Node<'_, '_>) -> Result<MathExpr, MathParseError> {
2416    let open = parse_fence_attr(node.attribute("open").unwrap_or("("));
2417    let close = parse_fence_attr(node.attribute("close").unwrap_or(")"));
2418    let separator = match node.attribute("separators") {
2419        Some(value) => value
2420            .chars()
2421            .find(|ch| !ch.is_whitespace())
2422            .map(|ch| ch.to_string()),
2423        None => Some(",".to_string()),
2424    };
2425    let children = parse_mathml_children(node)?;
2426    let mut body = Vec::new();
2427    for (index, child) in children.into_iter().enumerate() {
2428        if index > 0
2429            && let Some(separator) = &separator
2430        {
2431            body.push(MathExpr::Operator(separator.clone()));
2432        }
2433        body.push(child);
2434    }
2435    Ok(MathExpr::Fenced {
2436        open,
2437        close,
2438        body: Arc::new(MathExpr::row(body)),
2439    })
2440}
2441
2442fn parse_fence_attr(value: &str) -> Option<String> {
2443    let value = value.trim();
2444    if value.is_empty() || value == "." {
2445        None
2446    } else {
2447        Some(value.to_string())
2448    }
2449}
2450
2451fn require_mathml_tag(node: roxmltree::Node<'_, '_>, expected: &str) -> Result<(), MathParseError> {
2452    if node.tag_name().name() == expected {
2453        Ok(())
2454    } else {
2455        Err(mathml_error_at(
2456            node,
2457            format!(
2458                "expected <{expected}> element, got <{}>",
2459                node.tag_name().name()
2460            ),
2461        ))
2462    }
2463}
2464
2465fn normalized_node_text(node: roxmltree::Node<'_, '_>) -> String {
2466    node.descendants()
2467        .filter(roxmltree::Node::is_text)
2468        .filter_map(|n| n.text())
2469        .collect::<String>()
2470        .split_whitespace()
2471        .collect::<Vec<_>>()
2472        .join(" ")
2473}
2474
2475fn parse_mathml_space(node: roxmltree::Node<'_, '_>) -> f32 {
2476    node.attribute("width")
2477        .and_then(parse_em_length)
2478        .unwrap_or(0.3)
2479}
2480
2481fn parse_em_length(s: &str) -> Option<f32> {
2482    let trimmed = s.trim();
2483    if let Some(number) = trimmed.strip_suffix("em") {
2484        return number.trim().parse().ok();
2485    }
2486    if let Some(number) = trimmed.strip_suffix("px") {
2487        return number.trim().parse::<f32>().ok().map(|px| px / 16.0);
2488    }
2489    trimmed.parse().ok()
2490}
2491
2492fn mathml_error_at(node: roxmltree::Node<'_, '_>, message: String) -> MathParseError {
2493    MathParseError {
2494        message,
2495        byte: node.range().start,
2496    }
2497}
2498
2499fn text_pos_to_byte(input: &str, row: u32, col: u32) -> usize {
2500    let mut current_row = 1;
2501    let mut current_col = 1;
2502    for (byte, ch) in input.char_indices() {
2503        if current_row == row && current_col == col {
2504            return byte;
2505        }
2506        if ch == '\n' {
2507            current_row += 1;
2508            current_col = 1;
2509        } else {
2510            current_col += 1;
2511        }
2512    }
2513    input.len()
2514}
2515
2516struct TexParser<'a> {
2517    input: &'a str,
2518    pos: usize,
2519    source_ranges: bool,
2520}
2521
2522impl<'a> TexParser<'a> {
2523    fn new(input: &'a str) -> Self {
2524        Self {
2525            input,
2526            pos: 0,
2527            source_ranges: false,
2528        }
2529    }
2530
2531    fn with_source_ranges(input: &'a str) -> Self {
2532        Self {
2533            input,
2534            pos: 0,
2535            source_ranges: true,
2536        }
2537    }
2538
2539    fn source_wrap(&self, start: usize, expr: MathExpr) -> MathExpr {
2540        if self.source_ranges {
2541            MathExpr::Source {
2542                source: start..self.pos,
2543                body: Arc::new(expr),
2544            }
2545        } else {
2546            expr
2547        }
2548    }
2549
2550    fn parse_row(&mut self, until: Option<char>) -> Result<MathExpr, MathParseError> {
2551        let start = self.pos;
2552        let mut items = Vec::new();
2553        loop {
2554            self.skip_ws();
2555            if self.starts_with_command("right") {
2556                return Err(self.error("unexpected \\right"));
2557            }
2558            match self.peek() {
2559                None => {
2560                    if until.is_some() {
2561                        return Err(self.error("unclosed group"));
2562                    }
2563                    break;
2564                }
2565                Some(ch) if Some(ch) == until => {
2566                    self.bump();
2567                    break;
2568                }
2569                Some('}') => return Err(self.error("unexpected closing brace")),
2570                _ => {
2571                    let atom = self.parse_atom_with_scripts()?;
2572                    items.push(atom);
2573                }
2574            }
2575        }
2576        let expr = MathExpr::row(items);
2577        Ok(self.source_wrap(start, expr))
2578    }
2579
2580    fn parse_row_until_right(&mut self) -> Result<MathExpr, MathParseError> {
2581        let start = self.pos;
2582        let mut items = Vec::new();
2583        loop {
2584            self.skip_ws();
2585            if self.peek().is_none() {
2586                return Err(self.error("unclosed \\left"));
2587            }
2588            if self.starts_with_command("right") {
2589                break;
2590            }
2591            if self.peek() == Some('}') {
2592                return Err(self.error("unexpected closing brace"));
2593            }
2594            let atom = self.parse_atom_with_scripts()?;
2595            items.push(atom);
2596        }
2597        let expr = MathExpr::row(items);
2598        Ok(self.source_wrap(start, expr))
2599    }
2600
2601    fn parse_table_environment(
2602        &mut self,
2603        env: &str,
2604        column_alignments: Vec<MathColumnAlignment>,
2605        column_gap: Option<f32>,
2606        row_gap: Option<f32>,
2607    ) -> Result<MathExpr, MathParseError> {
2608        let mut rows = Vec::new();
2609        let mut row = Vec::new();
2610        let mut cell = Vec::new();
2611
2612        loop {
2613            self.skip_ws();
2614            if self.peek().is_none() {
2615                return Err(self.error(&format!("unclosed \\begin{{{env}}}")));
2616            }
2617            if self.starts_with_command("end") {
2618                self.consume_environment_end(env)?;
2619                if !row.is_empty() || !cell.is_empty() || rows.is_empty() {
2620                    row.push(MathExpr::row(std::mem::take(&mut cell)));
2621                    rows.push(row);
2622                }
2623                break;
2624            }
2625            if self.peek() == Some('&') {
2626                self.bump();
2627                row.push(MathExpr::row(std::mem::take(&mut cell)));
2628                continue;
2629            }
2630            if self.starts_with_row_separator() {
2631                self.consume_row_separator()?;
2632                row.push(MathExpr::row(std::mem::take(&mut cell)));
2633                rows.push(std::mem::take(&mut row));
2634                continue;
2635            }
2636
2637            cell.push(self.parse_atom_with_scripts()?);
2638        }
2639
2640        self.validate_tex_table_shape(env, &rows, &column_alignments)?;
2641
2642        let table = MathExpr::Table {
2643            rows,
2644            column_alignments,
2645            column_gap,
2646            row_gap,
2647        };
2648        Ok(match env {
2649            "matrix" | "array" | "aligned" | "align" => table,
2650            "pmatrix" => MathExpr::Fenced {
2651                open: Some("(".into()),
2652                close: Some(")".into()),
2653                body: Arc::new(table),
2654            },
2655            "bmatrix" => MathExpr::Fenced {
2656                open: Some("[".into()),
2657                close: Some("]".into()),
2658                body: Arc::new(table),
2659            },
2660            "Bmatrix" => MathExpr::Fenced {
2661                open: Some("{".into()),
2662                close: Some("}".into()),
2663                body: Arc::new(table),
2664            },
2665            "vmatrix" => MathExpr::Fenced {
2666                open: Some("|".into()),
2667                close: Some("|".into()),
2668                body: Arc::new(table),
2669            },
2670            "Vmatrix" => MathExpr::Fenced {
2671                open: Some("‖".into()),
2672                close: Some("‖".into()),
2673                body: Arc::new(table),
2674            },
2675            "cases" => MathExpr::Fenced {
2676                open: Some("{".into()),
2677                close: None,
2678                body: Arc::new(table),
2679            },
2680            _ => return Err(self.error(&format!("unsupported math environment {env}"))),
2681        })
2682    }
2683
2684    fn validate_tex_table_shape(
2685        &self,
2686        env: &str,
2687        rows: &[Vec<MathExpr>],
2688        column_alignments: &[MathColumnAlignment],
2689    ) -> Result<(), MathParseError> {
2690        let Some(first_row) = rows.first() else {
2691            return Ok(());
2692        };
2693        let expected_cols = first_row.len();
2694        for (row_index, row) in rows.iter().enumerate().skip(1) {
2695            if row.len() != expected_cols {
2696                return Err(self.error(&format!(
2697                    "inconsistent column count in {env}: row {} has {}, expected {expected_cols}",
2698                    row_index + 1,
2699                    row.len()
2700                )));
2701            }
2702        }
2703        if !column_alignments.is_empty() && column_alignments.len() != expected_cols {
2704            return Err(self.error(&format!(
2705                "{env} alignment spec has {} columns, but table has {expected_cols}",
2706                column_alignments.len()
2707            )));
2708        }
2709        Ok(())
2710    }
2711
2712    fn parse_atom_with_scripts(&mut self) -> Result<MathExpr, MathParseError> {
2713        let start = self.pos;
2714        let mut base = self.parse_atom()?;
2715        let mut sub = None;
2716        let mut sup = None;
2717        loop {
2718            self.skip_ws();
2719            match self.peek() {
2720                Some('_') => {
2721                    self.bump();
2722                    sub = Some(Arc::new(self.parse_script_arg()?));
2723                }
2724                Some('^') => {
2725                    self.bump();
2726                    sup = Some(Arc::new(self.parse_script_arg()?));
2727                }
2728                _ => break,
2729            }
2730        }
2731        if sub.is_some() || sup.is_some() {
2732            base = MathExpr::Scripts {
2733                base: Arc::new(base),
2734                sub,
2735                sup,
2736            };
2737            base = self.source_wrap(start, base);
2738        }
2739        Ok(base)
2740    }
2741
2742    fn parse_script_arg(&mut self) -> Result<MathExpr, MathParseError> {
2743        self.skip_ws();
2744        if self.peek() == Some('{') {
2745            self.bump();
2746            self.parse_row(Some('}'))
2747        } else {
2748            self.parse_atom()
2749        }
2750    }
2751
2752    fn parse_atom(&mut self) -> Result<MathExpr, MathParseError> {
2753        self.skip_ws();
2754        let start = self.pos;
2755        match self.peek() {
2756            Some('{') => {
2757                self.bump();
2758                let expr = self.parse_row(Some('}'))?;
2759                Ok(self.source_wrap(start, expr))
2760            }
2761            Some('\\') => self.parse_command(),
2762            Some(ch) if ch.is_ascii_digit() => {
2763                let text = self.take_while(|c| c.is_ascii_digit() || c == '.');
2764                Ok(self.source_wrap(start, MathExpr::Number(text)))
2765            }
2766            Some(ch) if ch.is_alphabetic() => {
2767                self.bump();
2768                Ok(self.source_wrap(start, MathExpr::Identifier(ch.to_string())))
2769            }
2770            Some(ch) => {
2771                self.bump();
2772                let expr = if ch.is_whitespace() {
2773                    MathExpr::Space(0.3)
2774                } else {
2775                    MathExpr::Operator(ch.to_string())
2776                };
2777                Ok(self.source_wrap(start, expr))
2778            }
2779            None => Err(self.error("expected math atom")),
2780        }
2781    }
2782
2783    fn parse_command(&mut self) -> Result<MathExpr, MathParseError> {
2784        let start = self.pos;
2785        let expr = self.parse_command_unwrapped()?;
2786        Ok(self.source_wrap(start, expr))
2787    }
2788
2789    fn parse_command_unwrapped(&mut self) -> Result<MathExpr, MathParseError> {
2790        self.expect('\\')?;
2791        let name = self.take_while(|c| c.is_ascii_alphabetic());
2792        if name.is_empty() {
2793            let escaped = self
2794                .bump()
2795                .ok_or_else(|| self.error("expected escaped character"))?;
2796            return Ok(match escaped {
2797                ',' => MathExpr::Space(THIN_MATH_SPACE_EM),
2798                ':' => MathExpr::Space(MEDIUM_MATH_SPACE_EM),
2799                ';' => MathExpr::Space(THICK_MATH_SPACE_EM),
2800                '!' => MathExpr::Space(-THIN_MATH_SPACE_EM),
2801                ' ' => MathExpr::Space(MEDIUM_MATH_SPACE_EM),
2802                _ => MathExpr::Operator(escaped.to_string()),
2803            });
2804        }
2805        match name.as_str() {
2806            "frac" | "tfrac" | "dfrac" => {
2807                let numerator = Arc::new(self.parse_required_group()?);
2808                let denominator = Arc::new(self.parse_required_group()?);
2809                Ok(MathExpr::Fraction {
2810                    numerator,
2811                    denominator,
2812                })
2813            }
2814            "binom" => {
2815                let numerator = self.parse_required_group()?;
2816                let denominator = self.parse_required_group()?;
2817                Ok(MathExpr::Fenced {
2818                    open: Some("(".into()),
2819                    close: Some(")".into()),
2820                    body: Arc::new(MathExpr::Table {
2821                        rows: vec![vec![numerator], vec![denominator]],
2822                        column_alignments: Vec::new(),
2823                        column_gap: None,
2824                        row_gap: None,
2825                    }),
2826                })
2827            }
2828            "sqrt" => {
2829                let index = self.parse_optional_bracket_group()?;
2830                let base = Arc::new(self.parse_required_group()?);
2831                Ok(match index {
2832                    Some(index) => MathExpr::Root {
2833                        base,
2834                        index: Arc::new(index),
2835                    },
2836                    None => MathExpr::Sqrt(base),
2837                })
2838            }
2839            "hat" | "widehat" => Ok(MathExpr::Accent {
2840                base: Arc::new(self.parse_required_group()?),
2841                accent: Arc::new(MathExpr::Operator("ˆ".into())),
2842                stretch: false,
2843            }),
2844            "bar" => Ok(MathExpr::Accent {
2845                base: Arc::new(self.parse_required_group()?),
2846                accent: Arc::new(MathExpr::Operator("¯".into())),
2847                stretch: false,
2848            }),
2849            "overline" => Ok(MathExpr::Accent {
2850                base: Arc::new(self.parse_required_group()?),
2851                accent: Arc::new(MathExpr::Operator("‾".into())),
2852                stretch: true,
2853            }),
2854            "vec" => Ok(MathExpr::Accent {
2855                base: Arc::new(self.parse_required_group()?),
2856                accent: Arc::new(MathExpr::Operator("→".into())),
2857                stretch: false,
2858            }),
2859            "tilde" | "widetilde" => Ok(MathExpr::Accent {
2860                base: Arc::new(self.parse_required_group()?),
2861                accent: Arc::new(MathExpr::Operator("˜".into())),
2862                stretch: false,
2863            }),
2864            "left" => {
2865                let open = self.parse_delimiter()?;
2866                let body = Arc::new(self.parse_row_until_right()?);
2867                self.consume_command("right")?;
2868                let close = self.parse_delimiter()?;
2869                Ok(MathExpr::Fenced { open, close, body })
2870            }
2871            "right" => Err(self.error("unexpected \\right")),
2872            "begin" => {
2873                let env = self.parse_environment_name()?;
2874                match env.as_str() {
2875                    "matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix"
2876                    | "cases" | "aligned" | "align" => {
2877                        let options = default_tex_table_options(&env);
2878                        self.parse_table_environment(
2879                            &env,
2880                            options.column_alignments,
2881                            options.column_gap,
2882                            options.row_gap,
2883                        )
2884                    }
2885                    "array" => {
2886                        let column_alignments = self.parse_array_column_alignments()?;
2887                        self.parse_table_environment(&env, column_alignments, None, None)
2888                    }
2889                    _ => Err(self.error(&format!("unsupported math environment {env}"))),
2890                }
2891            }
2892            "end" => Err(self.error("unexpected \\end")),
2893            "text" | "mathrm" | "operatorname" => Ok(MathExpr::Text(self.parse_text_group()?)),
2894            "mathbf" | "boldsymbol" | "mathcal" => self.parse_required_group(),
2895            "mathbb" => {
2896                let expr = self.parse_required_group()?;
2897                Ok(map_mathbb_expr(expr))
2898            }
2899            // Greek lowercase
2900            "alpha" => Ok(MathExpr::Identifier("α".into())),
2901            "beta" => Ok(MathExpr::Identifier("β".into())),
2902            "gamma" => Ok(MathExpr::Identifier("γ".into())),
2903            "delta" => Ok(MathExpr::Identifier("δ".into())),
2904            "varepsilon" | "epsilon" => Ok(MathExpr::Identifier("ε".into())),
2905            "zeta" => Ok(MathExpr::Identifier("ζ".into())),
2906            "eta" => Ok(MathExpr::Identifier("η".into())),
2907            "theta" => Ok(MathExpr::Identifier("θ".into())),
2908            "vartheta" => Ok(MathExpr::Identifier("ϑ".into())),
2909            "iota" => Ok(MathExpr::Identifier("ι".into())),
2910            "kappa" => Ok(MathExpr::Identifier("κ".into())),
2911            "varkappa" => Ok(MathExpr::Identifier("ϰ".into())),
2912            "lambda" => Ok(MathExpr::Identifier("λ".into())),
2913            "mu" => Ok(MathExpr::Identifier("μ".into())),
2914            "nu" => Ok(MathExpr::Identifier("ν".into())),
2915            "xi" => Ok(MathExpr::Identifier("ξ".into())),
2916            "pi" => Ok(MathExpr::Identifier("π".into())),
2917            "varpi" => Ok(MathExpr::Identifier("ϖ".into())),
2918            "rho" => Ok(MathExpr::Identifier("ρ".into())),
2919            "varrho" => Ok(MathExpr::Identifier("ϱ".into())),
2920            "sigma" => Ok(MathExpr::Identifier("σ".into())),
2921            "varsigma" => Ok(MathExpr::Identifier("ς".into())),
2922            "tau" => Ok(MathExpr::Identifier("τ".into())),
2923            "upsilon" => Ok(MathExpr::Identifier("υ".into())),
2924            "phi" | "varphi" => Ok(MathExpr::Identifier("φ".into())),
2925            "chi" => Ok(MathExpr::Identifier("χ".into())),
2926            "psi" => Ok(MathExpr::Identifier("ψ".into())),
2927            "omega" => Ok(MathExpr::Identifier("ω".into())),
2928            // Greek uppercase
2929            "Gamma" => Ok(MathExpr::Identifier("Γ".into())),
2930            "Delta" => Ok(MathExpr::Identifier("Δ".into())),
2931            "Theta" => Ok(MathExpr::Identifier("Θ".into())),
2932            "Lambda" => Ok(MathExpr::Identifier("Λ".into())),
2933            "Xi" => Ok(MathExpr::Identifier("Ξ".into())),
2934            "Pi" => Ok(MathExpr::Identifier("Π".into())),
2935            "Sigma" => Ok(MathExpr::Identifier("Σ".into())),
2936            "Upsilon" => Ok(MathExpr::Identifier("Υ".into())),
2937            "Phi" => Ok(MathExpr::Identifier("Φ".into())),
2938            "Psi" => Ok(MathExpr::Identifier("Ψ".into())),
2939            "Omega" => Ok(MathExpr::Identifier("Ω".into())),
2940            // Other identifiers
2941            "partial" => Ok(MathExpr::Identifier("∂".into())),
2942            "infty" => Ok(MathExpr::Identifier("∞".into())),
2943            "hbar" => Ok(MathExpr::Identifier("ℏ".into())),
2944            "Re" => Ok(MathExpr::Identifier("ℜ".into())),
2945            "Im" => Ok(MathExpr::Identifier("ℑ".into())),
2946            "aleph" => Ok(MathExpr::Identifier("ℵ".into())),
2947            "beth" => Ok(MathExpr::Identifier("ℶ".into())),
2948            "wp" => Ok(MathExpr::Identifier("℘".into())),
2949            "ell" => Ok(MathExpr::Identifier("ℓ".into())),
2950            "emptyset" | "varnothing" => Ok(MathExpr::Identifier("∅".into())),
2951            "triangle" => Ok(MathExpr::Identifier("△".into())),
2952            "square" => Ok(MathExpr::Identifier("□".into())),
2953            "flat" => Ok(MathExpr::Identifier("♭".into())),
2954            "sharp" => Ok(MathExpr::Identifier("♯".into())),
2955            "natural" => Ok(MathExpr::Identifier("♮".into())),
2956            // Binary operators
2957            "pm" => Ok(MathExpr::Operator("±".into())),
2958            "mp" => Ok(MathExpr::Operator("∓".into())),
2959            "cdot" => Ok(MathExpr::Operator("·".into())),
2960            "times" => Ok(MathExpr::Operator("×".into())),
2961            "div" => Ok(MathExpr::Operator("÷".into())),
2962            "cup" => Ok(MathExpr::Operator("∪".into())),
2963            "cap" => Ok(MathExpr::Operator("∩".into())),
2964            "wedge" | "land" => Ok(MathExpr::Operator("∧".into())),
2965            "vee" | "lor" => Ok(MathExpr::Operator("∨".into())),
2966            "oplus" => Ok(MathExpr::Operator("⊕".into())),
2967            "ominus" => Ok(MathExpr::Operator("⊖".into())),
2968            "otimes" => Ok(MathExpr::Operator("⊗".into())),
2969            "oslash" => Ok(MathExpr::Operator("⊘".into())),
2970            "odot" => Ok(MathExpr::Operator("⊙".into())),
2971            "circ" => Ok(MathExpr::Operator("∘".into())),
2972            "bullet" => Ok(MathExpr::Operator("•".into())),
2973            "star" => Ok(MathExpr::Operator("⋆".into())),
2974            "ast" => Ok(MathExpr::Operator("∗".into())),
2975            "sqcup" => Ok(MathExpr::Operator("⊔".into())),
2976            "sqcap" => Ok(MathExpr::Operator("⊓".into())),
2977            "amalg" => Ok(MathExpr::Operator("⨿".into())),
2978            "wr" => Ok(MathExpr::Operator("≀".into())),
2979            "triangleleft" => Ok(MathExpr::Operator("◁".into())),
2980            "triangleright" => Ok(MathExpr::Operator("▷".into())),
2981            "diamond" => Ok(MathExpr::Operator("⋄".into())),
2982            "setminus" | "smallsetminus" => Ok(MathExpr::Operator("∖".into())),
2983            // Relations
2984            "approx" => Ok(MathExpr::Operator("≈".into())),
2985            "sim" => Ok(MathExpr::Operator("∼".into())),
2986            "simeq" => Ok(MathExpr::Operator("≃".into())),
2987            "cong" => Ok(MathExpr::Operator("≅".into())),
2988            "equiv" => Ok(MathExpr::Operator("≡".into())),
2989            "propto" => Ok(MathExpr::Operator("∝".into())),
2990            "le" | "leq" => Ok(MathExpr::Operator("≤".into())),
2991            "ge" | "geq" => Ok(MathExpr::Operator("≥".into())),
2992            "ne" | "neq" => Ok(MathExpr::Operator("≠".into())),
2993            "ll" => Ok(MathExpr::Operator("≪".into())),
2994            "gg" => Ok(MathExpr::Operator("≫".into())),
2995            "prec" => Ok(MathExpr::Operator("≺".into())),
2996            "succ" => Ok(MathExpr::Operator("≻".into())),
2997            "preceq" => Ok(MathExpr::Operator("⪯".into())),
2998            "succeq" => Ok(MathExpr::Operator("⪰".into())),
2999            "parallel" => Ok(MathExpr::Operator("∥".into())),
3000            "perp" => Ok(MathExpr::Operator("⊥".into())),
3001            "asymp" => Ok(MathExpr::Operator("≍".into())),
3002            "doteq" => Ok(MathExpr::Operator("≐".into())),
3003            "models" => Ok(MathExpr::Operator("⊨".into())),
3004            "vdash" => Ok(MathExpr::Operator("⊢".into())),
3005            "dashv" => Ok(MathExpr::Operator("⊣".into())),
3006            "therefore" => Ok(MathExpr::Operator("∴".into())),
3007            "because" => Ok(MathExpr::Operator("∵".into())),
3008            // Set theory
3009            "in" => Ok(MathExpr::Operator("∈".into())),
3010            "notin" => Ok(MathExpr::Operator("∉".into())),
3011            "ni" => Ok(MathExpr::Operator("∋".into())),
3012            "subset" => Ok(MathExpr::Operator("⊂".into())),
3013            "supset" => Ok(MathExpr::Operator("⊃".into())),
3014            "subseteq" => Ok(MathExpr::Operator("⊆".into())),
3015            "supseteq" => Ok(MathExpr::Operator("⊇".into())),
3016            "subsetneq" => Ok(MathExpr::Operator("⊊".into())),
3017            "supsetneq" => Ok(MathExpr::Operator("⊋".into())),
3018            "complement" => Ok(MathExpr::Operator("∁".into())),
3019            // Logic / quantifiers
3020            "forall" => Ok(MathExpr::Operator("∀".into())),
3021            "exists" => Ok(MathExpr::Operator("∃".into())),
3022            "nexists" => Ok(MathExpr::Operator("∄".into())),
3023            "neg" | "lnot" => Ok(MathExpr::Operator("¬".into())),
3024            "top" => Ok(MathExpr::Operator("⊤".into())),
3025            "bot" => Ok(MathExpr::Operator("⊥".into())),
3026            "implies" => Ok(extra_wide_operator("⟹")),
3027            "impliedby" => Ok(extra_wide_operator("⟸")),
3028            "iff" => Ok(extra_wide_operator("⟺")),
3029            // Arrows
3030            "to" | "rightarrow" => Ok(MathExpr::Operator("→".into())),
3031            "leftarrow" | "gets" => Ok(MathExpr::Operator("←".into())),
3032            "leftrightarrow" => Ok(MathExpr::Operator("↔".into())),
3033            "Rightarrow" => Ok(MathExpr::Operator("⇒".into())),
3034            "Leftarrow" => Ok(MathExpr::Operator("⇐".into())),
3035            "Leftrightarrow" => Ok(MathExpr::Operator("⇔".into())),
3036            "longrightarrow" => Ok(MathExpr::Operator("⟶".into())),
3037            "longleftarrow" => Ok(MathExpr::Operator("⟵".into())),
3038            "longleftrightarrow" => Ok(MathExpr::Operator("⟷".into())),
3039            "Longrightarrow" => Ok(MathExpr::Operator("⟹".into())),
3040            "Longleftarrow" => Ok(MathExpr::Operator("⟸".into())),
3041            "Longleftrightarrow" => Ok(MathExpr::Operator("⟺".into())),
3042            "uparrow" => Ok(MathExpr::Operator("↑".into())),
3043            "downarrow" => Ok(MathExpr::Operator("↓".into())),
3044            "updownarrow" => Ok(MathExpr::Operator("↕".into())),
3045            "Uparrow" => Ok(MathExpr::Operator("⇑".into())),
3046            "Downarrow" => Ok(MathExpr::Operator("⇓".into())),
3047            "Updownarrow" => Ok(MathExpr::Operator("⇕".into())),
3048            "mapsto" => Ok(MathExpr::Operator("↦".into())),
3049            "longmapsto" => Ok(MathExpr::Operator("⟼".into())),
3050            "hookrightarrow" => Ok(MathExpr::Operator("↪".into())),
3051            "hookleftarrow" => Ok(MathExpr::Operator("↩".into())),
3052            // Large operators
3053            "sum" => Ok(MathExpr::Operator("∑".into())),
3054            "prod" => Ok(MathExpr::Operator("∏".into())),
3055            "coprod" => Ok(MathExpr::Operator("∐".into())),
3056            "int" => Ok(MathExpr::Operator("∫".into())),
3057            "oint" => Ok(MathExpr::Operator("∮".into())),
3058            "iint" => Ok(MathExpr::Operator("∬".into())),
3059            "iiint" => Ok(MathExpr::Operator("∭".into())),
3060            "bigcup" => Ok(MathExpr::Operator("⋃".into())),
3061            "bigcap" => Ok(MathExpr::Operator("⋂".into())),
3062            "biguplus" => Ok(MathExpr::Operator("⨄".into())),
3063            "bigsqcup" => Ok(MathExpr::Operator("⨆".into())),
3064            "bigvee" => Ok(MathExpr::Operator("⋁".into())),
3065            "bigwedge" => Ok(MathExpr::Operator("⋀".into())),
3066            "bigoplus" => Ok(MathExpr::Operator("⨁".into())),
3067            "bigotimes" => Ok(MathExpr::Operator("⨂".into())),
3068            "bigodot" => Ok(MathExpr::Operator("⨀".into())),
3069            // Misc symbols
3070            "nabla" => Ok(MathExpr::Operator("∇".into())),
3071            "dagger" => Ok(MathExpr::Operator("†".into())),
3072            "ddagger" => Ok(MathExpr::Operator("‡".into())),
3073            "mid" => Ok(MathExpr::Operator("|".into())),
3074            "angle" => Ok(MathExpr::Operator("∠".into())),
3075            "measuredangle" => Ok(MathExpr::Operator("∡".into())),
3076            // Dots
3077            "ldots" | "dots" => Ok(MathExpr::Text("...".into())),
3078            "cdots" => Ok(MathExpr::Operator("⋯".into())),
3079            "vdots" => Ok(MathExpr::Operator("⋮".into())),
3080            "ddots" => Ok(MathExpr::Operator("⋱".into())),
3081            // Function-like operator names (rendered upright)
3082            "sin" | "cos" | "tan" | "cot" | "sec" | "csc" | "sinh" | "cosh" | "tanh" | "coth"
3083            | "arcsin" | "arccos" | "arctan" | "log" | "lg" | "ln" | "exp" | "lim" | "max"
3084            | "min" | "sup" | "inf" | "det" | "arg" | "deg" | "dim" | "hom" | "ker" => {
3085                Ok(MathExpr::Text(name))
3086            }
3087            "gcd" => Ok(MathExpr::Text("gcd".into())),
3088            "Pr" => Ok(MathExpr::Text("Pr".into())),
3089            "liminf" => Ok(MathExpr::Text("lim inf".into())),
3090            "limsup" => Ok(MathExpr::Text("lim sup".into())),
3091            // Spacing
3092            "quad" => Ok(MathExpr::Space(1.0)),
3093            "qquad" => Ok(MathExpr::Space(2.0)),
3094            "thinspace" => Ok(MathExpr::Space(THIN_MATH_SPACE_EM)),
3095            "medspace" => Ok(MathExpr::Space(MEDIUM_MATH_SPACE_EM)),
3096            "thickspace" => Ok(MathExpr::Space(THICK_MATH_SPACE_EM)),
3097            "negthinspace" => Ok(MathExpr::Space(-THIN_MATH_SPACE_EM)),
3098            "negmedspace" => Ok(MathExpr::Space(-MEDIUM_MATH_SPACE_EM)),
3099            "negthickspace" => Ok(MathExpr::Space(-THICK_MATH_SPACE_EM)),
3100            "enspace" => Ok(MathExpr::Space(0.5)),
3101            "space" => Ok(MathExpr::Space(MEDIUM_MATH_SPACE_EM)),
3102            _ => match delimiter_command(&name) {
3103                Some(symbol) => Ok(MathExpr::Operator(symbol)),
3104                None => Ok(MathExpr::Identifier(format!("\\{name}"))),
3105            },
3106        }
3107    }
3108
3109    fn parse_required_group(&mut self) -> Result<MathExpr, MathParseError> {
3110        self.skip_ws();
3111        self.expect('{')?;
3112        self.parse_row(Some('}'))
3113    }
3114
3115    fn parse_text_group(&mut self) -> Result<String, MathParseError> {
3116        self.skip_ws();
3117        self.expect('{')?;
3118        let mut depth = 1;
3119        let mut text = String::new();
3120        while let Some(ch) = self.bump() {
3121            match ch {
3122                '\\' => {
3123                    let escaped = self
3124                        .bump()
3125                        .ok_or_else(|| self.error("unclosed text group"))?;
3126                    text.push(escaped);
3127                }
3128                '{' => {
3129                    depth += 1;
3130                    text.push(ch);
3131                }
3132                '}' => {
3133                    depth -= 1;
3134                    if depth == 0 {
3135                        return Ok(text.split_whitespace().collect::<Vec<_>>().join(" "));
3136                    }
3137                    text.push(ch);
3138                }
3139                _ => text.push(ch),
3140            }
3141        }
3142        Err(self.error("unclosed text group"))
3143    }
3144
3145    fn parse_optional_bracket_group(&mut self) -> Result<Option<MathExpr>, MathParseError> {
3146        self.skip_ws();
3147        if self.peek() != Some('[') {
3148            return Ok(None);
3149        }
3150        self.bump();
3151        self.parse_row(Some(']')).map(Some)
3152    }
3153
3154    fn parse_delimiter(&mut self) -> Result<Option<String>, MathParseError> {
3155        self.skip_ws();
3156        let delimiter = match self.bump() {
3157            Some('.') => return Ok(None),
3158            Some('\\') => {
3159                let name = self.take_while(|c| c.is_ascii_alphabetic());
3160                if name.is_empty() {
3161                    self.bump()
3162                        .ok_or_else(|| self.error("expected delimiter after escape"))?
3163                        .to_string()
3164                } else {
3165                    delimiter_command(&name).unwrap_or_else(|| format!("\\{name}"))
3166                }
3167            }
3168            Some(ch) => ch.to_string(),
3169            None => return Err(self.error("expected delimiter")),
3170        };
3171        Ok(Some(delimiter))
3172    }
3173
3174    fn parse_environment_name(&mut self) -> Result<String, MathParseError> {
3175        self.skip_ws();
3176        self.expect('{')?;
3177        let name = self.take_while(|c| c != '}');
3178        self.expect('}')?;
3179        if name.is_empty() {
3180            return Err(self.error("expected environment name"));
3181        }
3182        Ok(name)
3183    }
3184
3185    fn parse_array_column_alignments(
3186        &mut self,
3187    ) -> Result<Vec<MathColumnAlignment>, MathParseError> {
3188        self.skip_ws();
3189        self.expect('{')?;
3190        let mut alignments = Vec::new();
3191        loop {
3192            match self.bump() {
3193                Some('}') => break,
3194                Some('l') => alignments.push(MathColumnAlignment::Left),
3195                Some('c') => alignments.push(MathColumnAlignment::Center),
3196                Some('r') => alignments.push(MathColumnAlignment::Right),
3197                Some('|') | Some(' ') | Some('\t') | Some('\n') | Some('\r') => {}
3198                Some(ch) => {
3199                    return Err(
3200                        self.error(&format!("unsupported array alignment specifier {ch:?}"))
3201                    );
3202                }
3203                None => return Err(self.error("unclosed array alignment spec")),
3204            }
3205        }
3206        Ok(alignments)
3207    }
3208
3209    fn consume_environment_end(&mut self, expected: &str) -> Result<(), MathParseError> {
3210        self.consume_command("end")?;
3211        let found = self.parse_environment_name()?;
3212        if found == expected {
3213            Ok(())
3214        } else {
3215            Err(self.error(&format!("expected \\end{{{expected}}}")))
3216        }
3217    }
3218
3219    fn starts_with_row_separator(&self) -> bool {
3220        self.input[self.pos..].starts_with(r"\\")
3221    }
3222
3223    fn consume_row_separator(&mut self) -> Result<(), MathParseError> {
3224        if !self.starts_with_row_separator() {
3225            return Err(self.error(r"expected \\"));
3226        }
3227        self.expect('\\')?;
3228        self.expect('\\')
3229    }
3230
3231    fn skip_ws(&mut self) {
3232        while matches!(self.peek(), Some(ch) if ch.is_whitespace()) {
3233            self.bump();
3234        }
3235    }
3236
3237    fn expect(&mut self, expected: char) -> Result<(), MathParseError> {
3238        match self.bump() {
3239            Some(ch) if ch == expected => Ok(()),
3240            _ => Err(self.error(&format!("expected '{expected}'"))),
3241        }
3242    }
3243
3244    fn take_while(&mut self, mut f: impl FnMut(char) -> bool) -> String {
3245        let start = self.pos;
3246        while matches!(self.peek(), Some(ch) if f(ch)) {
3247            self.bump();
3248        }
3249        self.input[start..self.pos].to_string()
3250    }
3251
3252    fn starts_with_command(&self, command: &str) -> bool {
3253        let rest = &self.input[self.pos..];
3254        let Some(after_slash) = rest.strip_prefix('\\') else {
3255            return false;
3256        };
3257        let Some(after_command) = after_slash.strip_prefix(command) else {
3258            return false;
3259        };
3260        !matches!(after_command.chars().next(), Some(ch) if ch.is_ascii_alphabetic())
3261    }
3262
3263    fn consume_command(&mut self, command: &str) -> Result<(), MathParseError> {
3264        if !self.starts_with_command(command) {
3265            return Err(self.error(&format!("expected \\{command}")));
3266        }
3267        self.expect('\\')?;
3268        let found = self.take_while(|c| c.is_ascii_alphabetic());
3269        if found == command {
3270            Ok(())
3271        } else {
3272            Err(self.error(&format!("expected \\{command}")))
3273        }
3274    }
3275
3276    fn peek(&self) -> Option<char> {
3277        self.input[self.pos..].chars().next()
3278    }
3279
3280    fn bump(&mut self) -> Option<char> {
3281        let ch = self.peek()?;
3282        self.pos += ch.len_utf8();
3283        Some(ch)
3284    }
3285
3286    fn error(&self, message: &str) -> MathParseError {
3287        MathParseError {
3288            message: message.to_string(),
3289            byte: self.pos,
3290        }
3291    }
3292}
3293
3294fn extra_wide_operator(symbol: &str) -> MathExpr {
3295    let spacing = MEDIUM_MATH_SPACE_EM + THICK_MATH_SPACE_EM;
3296    MathExpr::OperatorWithMetadata {
3297        text: symbol.into(),
3298        lspace: Some(spacing),
3299        rspace: Some(spacing),
3300        large_operator: None,
3301        movable_limits: None,
3302    }
3303}
3304
3305fn delimiter_command(command: &str) -> Option<String> {
3306    let delimiter = match command {
3307        "lbrace" => "{",
3308        "rbrace" => "}",
3309        "lparen" => "(",
3310        "rparen" => ")",
3311        "lbrack" => "[",
3312        "rbrack" => "]",
3313        "langle" => "⟨",
3314        "rangle" => "⟩",
3315        "vert" => "|",
3316        "Vert" => "‖",
3317        "lfloor" => "⌊",
3318        "rfloor" => "⌋",
3319        "lceil" => "⌈",
3320        "rceil" => "⌉",
3321        _ => return None,
3322    };
3323    Some(delimiter.to_string())
3324}
3325
3326fn map_mathbb_expr(expr: MathExpr) -> MathExpr {
3327    match expr {
3328        MathExpr::Identifier(text) => MathExpr::Identifier(map_mathbb_text(&text)),
3329        MathExpr::Text(text) => MathExpr::Text(map_mathbb_text(&text)),
3330        MathExpr::Row(children) => MathExpr::row(children.into_iter().map(map_mathbb_expr)),
3331        other => other,
3332    }
3333}
3334
3335fn map_mathbb_text(text: &str) -> String {
3336    text.chars().map(map_mathbb_char).collect()
3337}
3338
3339fn map_mathbb_char(ch: char) -> char {
3340    match ch {
3341        'A' => '𝔸',
3342        'B' => '𝔹',
3343        'C' => 'ℂ',
3344        'D' => '𝔻',
3345        'E' => '𝔼',
3346        'F' => '𝔽',
3347        'G' => '𝔾',
3348        'H' => 'ℍ',
3349        'I' => '𝕀',
3350        'J' => '𝕁',
3351        'K' => '𝕂',
3352        'L' => '𝕃',
3353        'M' => '𝕄',
3354        'N' => 'ℕ',
3355        'O' => '𝕆',
3356        'P' => 'ℙ',
3357        'Q' => 'ℚ',
3358        'R' => 'ℝ',
3359        'S' => '𝕊',
3360        'T' => '𝕋',
3361        'U' => '𝕌',
3362        'V' => '𝕍',
3363        'W' => '𝕎',
3364        'X' => '𝕏',
3365        'Y' => '𝕐',
3366        'Z' => 'ℤ',
3367        _ => ch,
3368    }
3369}
3370
3371struct TexTableOptions {
3372    column_alignments: Vec<MathColumnAlignment>,
3373    column_gap: Option<f32>,
3374    row_gap: Option<f32>,
3375}
3376
3377fn default_tex_table_options(env: &str) -> TexTableOptions {
3378    match env {
3379        "cases" => TexTableOptions {
3380            column_alignments: vec![MathColumnAlignment::Left, MathColumnAlignment::Left],
3381            column_gap: Some(CASES_COL_GAP_EM),
3382            row_gap: None,
3383        },
3384        "aligned" | "align" => TexTableOptions {
3385            column_alignments: vec![MathColumnAlignment::Right, MathColumnAlignment::Left],
3386            column_gap: Some(MEDIUM_MATH_SPACE_EM),
3387            row_gap: None,
3388        },
3389        _ => TexTableOptions {
3390            column_alignments: Vec::new(),
3391            column_gap: None,
3392            row_gap: None,
3393        },
3394    }
3395}
3396
3397pub(crate) fn math_glyph_layout(
3398    text: &str,
3399    size: f32,
3400    weight: FontWeight,
3401) -> text_metrics::TextLayout {
3402    text_metrics::layout_text_with_line_height_and_family(
3403        text,
3404        size,
3405        text_metrics::line_height(size),
3406        FontFamily::Inter,
3407        weight,
3408        false,
3409        TextWrap::NoWrap,
3410        None,
3411    )
3412}
3413
3414pub(crate) fn resolved_math_color(color: Option<Color>) -> Color {
3415    color.unwrap_or(crate::tokens::FOREGROUND)
3416}
3417
3418#[cfg(test)]
3419mod tests {
3420    use super::*;
3421
3422    fn has_radical_shape(layout: &MathLayout) -> bool {
3423        layout
3424            .atoms
3425            .iter()
3426            .any(|atom| matches!(atom, MathAtom::Radical { .. } | MathAtom::GlyphId { .. }))
3427    }
3428
3429    fn expect_source(expr: &MathExpr, expected: Range<usize>) -> &MathExpr {
3430        let MathExpr::Source { source, body } = expr else {
3431            panic!("expected source wrapper, got {expr:?}");
3432        };
3433        assert_eq!(*source, expected);
3434        body
3435    }
3436
3437    fn assert_no_unknown_tex_commands(expr: &MathExpr) {
3438        match expr {
3439            MathExpr::Identifier(text) => {
3440                assert!(
3441                    !text.starts_with('\\'),
3442                    "unexpected raw TeX command identifier {text:?} in {expr:?}"
3443                );
3444            }
3445            MathExpr::Row(children) => {
3446                for child in children {
3447                    assert_no_unknown_tex_commands(child);
3448                }
3449            }
3450            MathExpr::Fraction {
3451                numerator,
3452                denominator,
3453            } => {
3454                assert_no_unknown_tex_commands(numerator);
3455                assert_no_unknown_tex_commands(denominator);
3456            }
3457            MathExpr::Sqrt(child) => assert_no_unknown_tex_commands(child),
3458            MathExpr::Root { base, index } => {
3459                assert_no_unknown_tex_commands(base);
3460                assert_no_unknown_tex_commands(index);
3461            }
3462            MathExpr::Scripts { base, sub, sup } => {
3463                assert_no_unknown_tex_commands(base);
3464                if let Some(sub) = sub {
3465                    assert_no_unknown_tex_commands(sub);
3466                }
3467                if let Some(sup) = sup {
3468                    assert_no_unknown_tex_commands(sup);
3469                }
3470            }
3471            MathExpr::UnderOver { base, under, over } => {
3472                assert_no_unknown_tex_commands(base);
3473                if let Some(under) = under {
3474                    assert_no_unknown_tex_commands(under);
3475                }
3476                if let Some(over) = over {
3477                    assert_no_unknown_tex_commands(over);
3478                }
3479            }
3480            MathExpr::Accent { base, accent, .. } => {
3481                assert_no_unknown_tex_commands(base);
3482                assert_no_unknown_tex_commands(accent);
3483            }
3484            MathExpr::Fenced { body, .. } => assert_no_unknown_tex_commands(body),
3485            MathExpr::Table { rows, .. } => {
3486                for row in rows {
3487                    for cell in row {
3488                        assert_no_unknown_tex_commands(cell);
3489                    }
3490                }
3491            }
3492            MathExpr::Source { body, .. } => assert_no_unknown_tex_commands(body),
3493            MathExpr::Operator(_)
3494            | MathExpr::OperatorWithMetadata { .. }
3495            | MathExpr::Text(_)
3496            | MathExpr::Number(_)
3497            | MathExpr::Space(_)
3498            | MathExpr::Error(_) => {}
3499        }
3500    }
3501
3502    #[test]
3503    fn tex_source_ranges_are_opt_in_and_do_not_change_layout() {
3504        let input = r"\frac{x_1}{2}";
3505        let plain = parse_tex(input).expect("plain tex");
3506        let sourced = parse_tex_with_source_ranges(input).expect("source-backed tex");
3507
3508        assert!(!matches!(plain, MathExpr::Source { .. }));
3509        assert_eq!(
3510            layout_math(&plain, 16.0, MathDisplay::Block),
3511            layout_math(&sourced, 16.0, MathDisplay::Block)
3512        );
3513        assert!(matches!(
3514            expect_source(&sourced, 0..input.len()).without_source(),
3515            MathExpr::Fraction { .. }
3516        ));
3517    }
3518
3519    #[test]
3520    fn tex_source_ranges_track_script_components() {
3521        let expr = parse_tex_with_source_ranges("x_1^2").expect("source-backed tex");
3522        let root = expect_source(&expr, 0..5);
3523        let body = expect_source(root, 0..5);
3524        let MathExpr::Scripts { base, sub, sup } = body else {
3525            panic!("expected scripts, got {body:?}");
3526        };
3527
3528        assert_eq!(
3529            expect_source(base, 0..1).without_source(),
3530            &MathExpr::Identifier("x".into())
3531        );
3532        assert_eq!(
3533            expect_source(sub.as_deref().expect("subscript"), 2..3).without_source(),
3534            &MathExpr::Number("1".into())
3535        );
3536        assert_eq!(
3537            expect_source(sup.as_deref().expect("superscript"), 4..5).without_source(),
3538            &MathExpr::Number("2".into())
3539        );
3540    }
3541
3542    #[cfg(feature = "symbols")]
3543    #[test]
3544    fn loads_bundled_open_type_math_constants() {
3545        let constants = open_type_math_constants().expect("bundled math font has a MATH table");
3546        assert!(
3547            constants
3548                .script_scale(16.0)
3549                .is_some_and(|size| size > 6.0 && size < 16.0),
3550            "script scale should come from Noto Sans Math"
3551        );
3552        assert!(
3553            constants
3554                .fraction_rule_thickness(16.0)
3555                .is_some_and(|thickness| thickness > 0.75 && thickness < 2.0),
3556            "fraction rule thickness should come from Noto Sans Math"
3557        );
3558        assert!(
3559            constants
3560                .axis_height(16.0)
3561                .is_some_and(|axis| axis > 1.0 && axis < 8.0),
3562            "axis height should come from Noto Sans Math"
3563        );
3564        assert!(
3565            constants
3566                .superscript_shift_up(16.0)
3567                .is_some_and(|shift| shift > 1.0 && shift < 16.0),
3568            "superscript shift should come from Noto Sans Math"
3569        );
3570        assert!(
3571            constants
3572                .subscript_shift_down(16.0)
3573                .is_some_and(|shift| shift > 1.0 && shift < 16.0),
3574            "subscript shift should come from Noto Sans Math"
3575        );
3576        assert!(
3577            constants
3578                .space_after_script(16.0)
3579                .is_some_and(|space| space > 0.1 && space < 4.0),
3580            "script spacing should come from Noto Sans Math"
3581        );
3582        assert!(
3583            constants
3584                .upper_limit_gap_min(16.0)
3585                .is_some_and(|gap| gap > 0.5 && gap < 8.0),
3586            "upper limit gap should come from Noto Sans Math"
3587        );
3588        assert!(
3589            constants
3590                .lower_limit_baseline_drop_min(16.0)
3591                .is_some_and(|drop| drop > 1.0 && drop < 20.0),
3592            "lower limit baseline drop should come from Noto Sans Math"
3593        );
3594        assert!(
3595            constants
3596                .fraction_numerator_gap(16.0, true)
3597                .is_some_and(|gap| gap > 0.5 && gap < 8.0),
3598            "display numerator gap should come from Noto Sans Math"
3599        );
3600        assert!(
3601            constants
3602                .fraction_denominator_gap(16.0, true)
3603                .is_some_and(|gap| gap > 0.5 && gap < 8.0),
3604            "display denominator gap should come from Noto Sans Math"
3605        );
3606        assert!(
3607            constants
3608                .fraction_numerator_shift(16.0, true)
3609                .is_some_and(|shift| shift > 1.0 && shift < 24.0),
3610            "display numerator shift should come from Noto Sans Math"
3611        );
3612        assert!(
3613            constants
3614                .fraction_denominator_shift(16.0, true)
3615                .is_some_and(|shift| shift > 1.0 && shift < 24.0),
3616            "display denominator shift should come from Noto Sans Math"
3617        );
3618        assert!(
3619            constants
3620                .radical_rule_thickness(16.0)
3621                .is_some_and(|thickness| thickness > 0.75 && thickness < 2.0),
3622            "radical rule thickness should come from Noto Sans Math"
3623        );
3624        assert!(
3625            constants
3626                .radical_vertical_gap(16.0, true)
3627                .is_some_and(|gap| gap > 0.5 && gap < 8.0),
3628            "display radical gap should come from Noto Sans Math"
3629        );
3630        assert!(
3631            constants
3632                .radical_kern_before_degree(16.0)
3633                .is_some_and(|kern| kern > 0.0 && kern < 8.0),
3634            "radical degree before-kern should come from Noto Sans Math"
3635        );
3636        assert!(
3637            constants
3638                .radical_kern_after_degree(16.0)
3639                .is_some_and(|kern| kern < 0.0 && kern > -8.0),
3640            "radical degree after-kern should come from Noto Sans Math"
3641        );
3642        assert!(
3643            constants
3644                .radical_degree_bottom_raise_fraction()
3645                .is_some_and(|raise| raise > 0.0 && raise < 1.0),
3646            "radical degree raise should come from Noto Sans Math"
3647        );
3648        assert!(
3649            constants
3650                .min_connector_overlap(16.0)
3651                .is_some_and(|overlap| overlap > 0.0),
3652            "delimiter connector overlap should come from Noto Sans Math"
3653        );
3654        assert!(
3655            constants
3656                .delimited_sub_formula_min_height(16.0)
3657                .is_some_and(|height| height > 8.0 && height < 40.0),
3658            "delimiter stretch threshold should come from Noto Sans Math"
3659        );
3660        assert!(
3661            constants.delimiter_variant_count('(') > 0,
3662            "left paren should expose vertical delimiter variants"
3663        );
3664        assert!(
3665            constants.delimiter_variant_count(RADICAL_GLYPH) > 0,
3666            "radical should expose vertical math glyph variants"
3667        );
3668        assert!(
3669            constants.delimiter_variant_count('∑') > 0,
3670            "summation should expose vertical math glyph variants"
3671        );
3672        assert!(
3673            constants.delimiter_variant_count('∫') > 0,
3674            "integral should expose vertical math glyph variants"
3675        );
3676        assert!(
3677            constants
3678                .delimiter_first_variant_glyph_id('(')
3679                .is_some_and(|glyph_id| glyph_id > 0),
3680            "left paren variants should preserve glyph IDs"
3681        );
3682        assert!(
3683            constants.delimiter_assembly_part_count('{') > 0,
3684            "left brace should expose a vertical delimiter assembly"
3685        );
3686        assert!(
3687            constants.delimiter_extender_part_count('{') > 0,
3688            "left brace assembly should expose extender parts"
3689        );
3690        assert!(
3691            constants.delimiter_has_assembly_connectors('{'),
3692            "left brace assembly should preserve connector metadata"
3693        );
3694        assert!(
3695            constants
3696                .delimiter_max_advance('(', 16.0)
3697                .is_some_and(|advance| advance > 16.0),
3698            "delimiter variant advances should scale into px"
3699        );
3700    }
3701
3702    #[test]
3703    fn parses_fraction_with_scripts() {
3704        let expr = parse_tex(r"\frac{a^2+b^2}{\sqrt{x_1+x_2}}").expect("valid tex");
3705        let layout = layout_math(&expr, 16.0, MathDisplay::Block);
3706        assert!(layout.width > 20.0, "width = {}", layout.width);
3707        assert!(layout.ascent > 10.0, "ascent = {}", layout.ascent);
3708        assert!(layout.descent > 10.0, "descent = {}", layout.descent);
3709        assert!(
3710            layout
3711                .atoms
3712                .iter()
3713                .any(|atom| matches!(atom, MathAtom::Rule { .. })),
3714            "fraction should emit rule atoms"
3715        );
3716        assert!(
3717            has_radical_shape(&layout),
3718            "sqrt should emit a radical shape atom"
3719        );
3720    }
3721
3722    #[test]
3723    fn display_fraction_honors_baseline_shifts() {
3724        let layout = layout_math(
3725            &parse_tex(r"\frac{1}{2}").unwrap(),
3726            16.0,
3727            MathDisplay::Block,
3728        );
3729        let metrics = LayoutCtx {
3730            size: 16.0,
3731            display: MathDisplay::Block,
3732        }
3733        .metrics();
3734        let numerator_y = layout
3735            .atoms
3736            .iter()
3737            .find_map(|atom| match atom {
3738                MathAtom::Glyph {
3739                    text, y_baseline, ..
3740                } if text == "1" => Some(*y_baseline),
3741                _ => None,
3742            })
3743            .expect("numerator baseline");
3744        let denominator_y = layout
3745            .atoms
3746            .iter()
3747            .find_map(|atom| match atom {
3748                MathAtom::Glyph {
3749                    text, y_baseline, ..
3750                } if text == "2" => Some(*y_baseline),
3751                _ => None,
3752            })
3753            .expect("denominator baseline");
3754
3755        assert!(
3756            -numerator_y >= metrics.fraction_numerator_shift() - 0.1,
3757            "numerator shift = {}, min = {}",
3758            -numerator_y,
3759            metrics.fraction_numerator_shift()
3760        );
3761        assert!(
3762            denominator_y >= metrics.fraction_denominator_shift() - 0.1,
3763            "denominator shift = {denominator_y}, min = {}",
3764            metrics.fraction_denominator_shift()
3765        );
3766    }
3767
3768    #[test]
3769    fn scripts_with_sub_and_sup_keep_minimum_gap() {
3770        let layout = layout_math(&parse_tex(r"x_1^2").unwrap(), 16.0, MathDisplay::Inline);
3771        let sub_top = layout
3772            .atoms
3773            .iter()
3774            .find_map(|atom| match atom {
3775                MathAtom::Glyph {
3776                    text,
3777                    y_baseline,
3778                    size,
3779                    ..
3780                } if text == "1" => Some(
3781                    y_baseline
3782                        - LayoutCtx {
3783                            size: *size,
3784                            display: MathDisplay::Inline,
3785                        }
3786                        .metrics()
3787                        .glyph_ascent(),
3788                ),
3789                _ => None,
3790            })
3791            .expect("subscript top");
3792        let sup_bottom = layout
3793            .atoms
3794            .iter()
3795            .find_map(|atom| match atom {
3796                MathAtom::Glyph {
3797                    text,
3798                    y_baseline,
3799                    size,
3800                    ..
3801                } if text == "2" => Some(
3802                    y_baseline
3803                        + LayoutCtx {
3804                            size: *size,
3805                            display: MathDisplay::Inline,
3806                        }
3807                        .metrics()
3808                        .glyph_descent(),
3809                ),
3810                _ => None,
3811            })
3812            .expect("superscript bottom");
3813        let min_gap = LayoutCtx {
3814            size: 16.0,
3815            display: MathDisplay::Inline,
3816        }
3817        .metrics()
3818        .sub_superscript_gap();
3819
3820        assert!(
3821            sub_top - sup_bottom >= min_gap - 0.1,
3822            "script gap = {}, min = {min_gap}",
3823            sub_top - sup_bottom
3824        );
3825    }
3826
3827    #[test]
3828    fn parses_indexed_tex_root() {
3829        let expr = parse_tex(r"\sqrt[3]{x+1}").expect("valid tex");
3830        match expr {
3831            MathExpr::Root { base, index } => {
3832                assert_eq!(*index, MathExpr::Number("3".into()));
3833                assert!(matches!(*base, MathExpr::Row(_)));
3834            }
3835            other => panic!("expected indexed root, got {other:?}"),
3836        }
3837        let layout = layout_math(
3838            &parse_tex(r"\sqrt[3]{x+1}").unwrap(),
3839            16.0,
3840            MathDisplay::Inline,
3841        );
3842        assert!(
3843            has_radical_shape(&layout),
3844            "indexed root should emit a radical shape atom"
3845        );
3846    }
3847
3848    #[test]
3849    fn indexed_root_uses_open_type_degree_metrics() {
3850        let ctx = LayoutCtx {
3851            size: 16.0,
3852            display: MathDisplay::Inline,
3853        };
3854        let metrics = ctx.metrics();
3855        let base = parse_tex(r"x+1").expect("valid root base");
3856        let index_expr = MathExpr::Number("3".into());
3857        let root = layout_sqrt(&base, ctx);
3858        let index = layout_expr(&index_expr, ctx.script());
3859        let layout = layout_root(&base, &index_expr, ctx);
3860        let constants = metrics.font_constants().expect("bundled math constants");
3861        let expected_root_x = (constants
3862            .radical_kern_before_degree(ctx.size)
3863            .unwrap_or(0.0)
3864            + index.width
3865            + constants.radical_kern_after_degree(ctx.size).unwrap_or(0.0))
3866        .max(index.width * 0.35);
3867        let expected_index_dy = -root.ascent
3868            * constants
3869                .radical_degree_bottom_raise_fraction()
3870                .expect("root degree raise")
3871            - index.descent;
3872        let index_atom = layout
3873            .atoms
3874            .iter()
3875            .find_map(|atom| match atom {
3876                MathAtom::Glyph {
3877                    text,
3878                    x,
3879                    y_baseline,
3880                    ..
3881                } if text == "3" => Some((*x, *y_baseline)),
3882                _ => None,
3883            })
3884            .expect("root index glyph");
3885        let root_x = layout
3886            .atoms
3887            .iter()
3888            .find_map(|atom| match atom {
3889                MathAtom::GlyphId { rect, .. } => Some(rect.x),
3890                MathAtom::Radical { points, .. } => Some(points[0][0]),
3891                _ => None,
3892            })
3893            .expect("root radical atom");
3894
3895        assert!(
3896            (index_atom.0 - 0.0).abs() < 0.1,
3897            "index x = {}",
3898            index_atom.0
3899        );
3900        assert!(
3901            (index_atom.1 - expected_index_dy).abs() < 0.1,
3902            "index baseline = {}, expected {expected_index_dy}",
3903            index_atom.1
3904        );
3905        assert!(
3906            (root_x - expected_root_x).abs() < 0.1,
3907            "root x = {root_x}, expected {expected_root_x}"
3908        );
3909    }
3910
3911    #[test]
3912    fn parses_tex_accents() {
3913        let expr = parse_tex(r"\hat{x} + \overline{ab} + \vec{v}").expect("valid tex accents");
3914        let MathExpr::Row(children) = expr else {
3915            panic!("expected row expression");
3916        };
3917        assert!(
3918            children
3919                .iter()
3920                .filter(|child| matches!(child, MathExpr::Accent { .. }))
3921                .count()
3922                >= 3,
3923            "expected accent expressions in {children:?}"
3924        );
3925
3926        let overline = layout_math(
3927            &parse_tex(r"\overline{ab}").unwrap(),
3928            16.0,
3929            MathDisplay::Inline,
3930        );
3931        assert!(
3932            overline
3933                .atoms
3934                .iter()
3935                .any(|atom| matches!(atom, MathAtom::Rule { rect } if rect.y < -10.0)),
3936            "overline should emit a rule above the base"
3937        );
3938    }
3939
3940    #[test]
3941    fn parses_tex_text_groups() {
3942        let expr = parse_tex(r"x \text{ if } y \operatorname{max}").expect("valid tex text");
3943        let MathExpr::Row(children) = expr else {
3944            panic!("expected row expression");
3945        };
3946        assert!(
3947            children
3948                .iter()
3949                .any(|child| matches!(child, MathExpr::Text(text) if text == "if")),
3950            "expected text group in {children:?}"
3951        );
3952        assert!(
3953            children
3954                .iter()
3955                .any(|child| matches!(child, MathExpr::Text(text) if text == "max")),
3956            "expected operatorname text in {children:?}"
3957        );
3958    }
3959
3960    #[test]
3961    fn parses_common_tex_symbol_commands() {
3962        let expr =
3963            parse_tex(r"\alpha+\beta\to\gamma+\emptyset+\varnothing").expect("valid tex symbols");
3964        let MathExpr::Row(children) = expr else {
3965            panic!("expected row expression");
3966        };
3967        assert!(
3968            children
3969                .iter()
3970                .any(|child| matches!(child, MathExpr::Identifier(text) if text == "∅")),
3971            "expected empty-set symbol in {children:?}"
3972        );
3973        assert!(
3974            children.iter().all(
3975                |child| !matches!(child, MathExpr::Identifier(text) if text.starts_with('\\'))
3976            ),
3977            "expected supported symbol commands in {children:?}"
3978        );
3979    }
3980
3981    #[test]
3982    fn parses_aligned_tex_environment() {
3983        let expr = parse_tex(
3984            r"\begin{aligned}
3985(a + b)^2 &= a^2 + 2ab + b^2 \\
3986(a - b)^2 &= a^2 - 2ab + b^2 \\
3987(a+b)(a-b) &= a^2 - b^2
3988\end{aligned}",
3989        )
3990        .expect("valid aligned environment");
3991
3992        let MathExpr::Table {
3993            rows,
3994            column_alignments,
3995            ..
3996        } = expr
3997        else {
3998            panic!("expected aligned environment to parse as table");
3999        };
4000        assert_eq!(rows.len(), 3);
4001        assert!(rows.iter().all(|row| row.len() == 2), "rows = {rows:?}");
4002        assert_eq!(
4003            column_alignments,
4004            vec![MathColumnAlignment::Right, MathColumnAlignment::Left]
4005        );
4006    }
4007
4008    #[test]
4009    fn parses_markdown_math_stress_tex_commands() {
4010        let formulas = [
4011            r"x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}",
4012            r"\int_{-\infty}^{\infty} e^{-x^2}\, dx = \sqrt{\pi}",
4013            r"\hat{f}(\xi) = \int_{-\infty}^{\infty} f(x)\, e^{-2\pi i x \xi}\, dx",
4014            r"\nabla \cdot \mathbf{E} = \frac{\rho}{\varepsilon_0}",
4015            r"\nabla \times \mathbf{B} = \mu_0 \mathbf{J} + \mu_0 \varepsilon_0 \frac{\partial \mathbf{E}}{\partial t}",
4016            r"\begin{aligned}
4017S &= \sum_{k=0}^{n} r^k = 1 + r + r^2 + \cdots + r^n \\
4018rS &= r + r^2 + \cdots + r^{n+1} \\
4019S - rS &= 1 - r^{n+1} \\
4020S &= \frac{1 - r^{n+1}}{1 - r}, \quad r \neq 1
4021\end{aligned}",
4022            r"R(\theta) = \begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix}",
4023            r"\det(A) = \begin{vmatrix} a & b & c \\ d & e & f \\ g & h & i \end{vmatrix}",
4024            r"f'(x) = \lim_{h \to 0} \frac{f(x+h) - f(x)}{h}",
4025            r"P(A \mid B) = \frac{P(B \mid A)\, P(A)}{P(B)}",
4026            r"f(x \mid \mu, \sigma^2) = \frac{1}{\sqrt{2\pi\sigma^2}}",
4027            r"\mathbb{E}[X] = \int_{-\infty}^{\infty} x\, f(x)\, dx, \qquad \operatorname{Var}(X) = \mathbb{E}[X^2] - (\mathbb{E}[X])^2",
4028            r"( x + y )^n = \sum_{k=0}^{n} \binom{n}{k} x^{n-k} y^k",
4029            r"\varphi(n) = n \prod_{p \mid n} \left(1 - \frac{1}{p}\right)",
4030            r"A = A^\dagger",
4031            r"E_n = \frac{n^2 \pi^2 \hbar^2}{2mL^2}",
4032            r"|\langle \mathbf{u}, \mathbf{v} \rangle|^2 \leq \langle \mathbf{u}, \mathbf{u} \rangle \cdot \langle \mathbf{v}, \mathbf{v} \rangle",
4033            r"\alpha,\ \beta,\ \gamma,\ \delta,\ \varepsilon,\ \zeta,\ \eta,\ \theta,\ \iota,\ \kappa,\ \lambda,\ \mu,\ \nu,\ \xi,\ \pi,\ \rho,\ \sigma,\ \tau,\ \upsilon,\ \phi,\ \chi,\ \psi,\ \omega",
4034            r"\Gamma(n) = (n-1)! \qquad \Gamma\!\left(\tfrac{1}{2}\right) = \sqrt{\pi}",
4035            r"\zeta(s) = \sum_{n=1}^{\infty} \frac{1}{n^s}, \quad \Re(s) > 1",
4036        ];
4037
4038        for formula in formulas {
4039            let expr = parse_tex(formula)
4040                .unwrap_or_else(|err| panic!("failed to parse {formula:?}: {}", err.message));
4041            assert_no_unknown_tex_commands(&expr);
4042            let layout = layout_math(&expr, 16.0, MathDisplay::Block);
4043            assert!(
4044                layout.width.is_finite() && layout.height().is_finite(),
4045                "layout should be finite for {formula:?}: {layout:?}"
4046            );
4047        }
4048    }
4049
4050    #[test]
4051    fn operator_metadata_covers_spacing_and_large_ops() {
4052        let plus = operator_info("+");
4053        assert_eq!(plus.class, MathOperatorClass::Binary);
4054        assert!(plus.lspace_em > 0.0);
4055        assert!(plus.rspace_em > 0.0);
4056
4057        let comma = operator_info(",");
4058        assert_eq!(comma.class, MathOperatorClass::Punctuation);
4059        assert_eq!(comma.lspace_em, 0.0);
4060        assert!(comma.rspace_em > 0.0);
4061
4062        let sum = operator_info("∑");
4063        assert_eq!(sum.class, MathOperatorClass::Large);
4064        assert!(sum.large_operator);
4065        assert!(sum.movable_limits);
4066
4067        let integral = operator_info("∫");
4068        assert_eq!(integral.class, MathOperatorClass::Large);
4069        assert!(integral.large_operator);
4070        assert!(!integral.movable_limits);
4071
4072        // Expanded class coverage
4073        let wedge = operator_info("∧");
4074        assert_eq!(wedge.class, MathOperatorClass::Binary);
4075        let oplus = operator_info("⊕");
4076        assert_eq!(oplus.class, MathOperatorClass::Binary);
4077
4078        let element_of = operator_info("∈");
4079        assert_eq!(element_of.class, MathOperatorClass::Relation);
4080        let double_right_arrow = operator_info("⇒");
4081        assert_eq!(double_right_arrow.class, MathOperatorClass::Relation);
4082
4083        let coproduct = operator_info("∐");
4084        assert_eq!(coproduct.class, MathOperatorClass::Large);
4085        assert!(coproduct.movable_limits);
4086
4087        let contour_integral = operator_info("∮");
4088        assert_eq!(contour_integral.class, MathOperatorClass::Large);
4089        assert!(contour_integral.large_operator);
4090        assert!(!contour_integral.movable_limits);
4091    }
4092
4093    #[test]
4094    fn parses_logic_and_set_theory_commands() {
4095        let expr = parse_tex(r"\forall x \in S, \exists y \notin T \implies x \neq y")
4096            .expect("valid logic tex");
4097        assert_no_unknown_tex_commands(&expr);
4098
4099        let MathExpr::Row(children) = &expr else {
4100            panic!("expected row");
4101        };
4102
4103        assert!(
4104            children
4105                .iter()
4106                .any(|c| matches!(c.without_source(), MathExpr::Operator(s) if s == "∀")),
4107            "expected ∀ in {children:?}"
4108        );
4109        assert!(
4110            children
4111                .iter()
4112                .any(|c| matches!(c.without_source(), MathExpr::Operator(s) if s == "∃")),
4113            "expected ∃ in {children:?}"
4114        );
4115        assert!(
4116            children
4117                .iter()
4118                .any(|c| matches!(c.without_source(), MathExpr::Operator(s) if s == "∈")),
4119            "expected ∈ in {children:?}"
4120        );
4121        assert!(
4122            children
4123                .iter()
4124                .any(|c| matches!(c.without_source(), MathExpr::Operator(s) if s == "∉")),
4125            "expected ∉ in {children:?}"
4126        );
4127
4128        let implies = children
4129            .iter()
4130            .find_map(|child| match child.without_source() {
4131                MathExpr::OperatorWithMetadata {
4132                    text,
4133                    lspace,
4134                    rspace,
4135                    ..
4136                } if text == "⟹" => Some((*lspace, *rspace)),
4137                _ => None,
4138            })
4139            .expect("expected \\implies operator with metadata");
4140        let (lspace, rspace) = implies;
4141        // Should be wider than the default relation spacing.
4142        assert!(
4143            lspace.is_some_and(|v| v > MEDIUM_MATH_SPACE_EM),
4144            "lspace = {lspace:?}"
4145        );
4146        assert!(
4147            rspace.is_some_and(|v| v > MEDIUM_MATH_SPACE_EM),
4148            "rspace = {rspace:?}"
4149        );
4150    }
4151
4152    #[test]
4153    fn parses_function_like_operators_with_limits() {
4154        let expr = parse_tex(r"\gcd_{p \mid n}(p) + \liminf_{n \to \infty} a_n")
4155            .expect("valid function tex");
4156        assert_no_unknown_tex_commands(&expr);
4157
4158        let layout = layout_math(&expr, 22.0, MathDisplay::Block);
4159        assert!(layout.width.is_finite() && layout.height().is_finite());
4160
4161        // \gcd should be a Text base; \liminf renders with a thin space.
4162        assert!(is_display_limits_base(&MathExpr::Text("gcd".into())));
4163        assert!(is_display_limits_base(&MathExpr::Text("Pr".into())));
4164        assert!(is_display_limits_base(&MathExpr::Text("det".into())));
4165        assert!(is_display_limits_base(&MathExpr::Text("lim inf".into())));
4166        assert!(is_display_limits_base(&MathExpr::Text("lim sup".into())));
4167    }
4168
4169    #[test]
4170    fn parses_bare_delimiter_commands_as_operators() {
4171        let expr = parse_tex(r"\lfloor x \rfloor + \lceil y \rceil + \langle u, v \rangle")
4172            .expect("valid bare delimiter tex");
4173        assert_no_unknown_tex_commands(&expr);
4174        let MathExpr::Row(children) = &expr else {
4175            panic!("expected row");
4176        };
4177        for symbol in ["⌊", "⌋", "⌈", "⌉", "⟨", "⟩"] {
4178            assert!(
4179                children
4180                    .iter()
4181                    .any(|c| matches!(c.without_source(), MathExpr::Operator(s) if s == symbol)),
4182                "expected {symbol} in {children:?}"
4183            );
4184        }
4185    }
4186
4187    #[test]
4188    fn parses_arrows_and_big_operators() {
4189        let formulas = [
4190            r"f : A \hookrightarrow B",
4191            r"x \mapsto x^2",
4192            r"a \Rightarrow b \Leftrightarrow c",
4193            r"\bigoplus_{i=1}^{n} V_i \cong \bigotimes_{j=1}^{m} W_j",
4194            r"\oint_{\partial \Omega} \omega = \iint_{\Omega} d\omega",
4195            r"\coprod_{i \in I} X_i",
4196            r"\bigvee_{k} a_k \wedge \bigwedge_{k} b_k",
4197            r"A \subseteq B \subset C \supseteq D \supset E",
4198            r"x \equiv y",
4199            r"\therefore \forall \epsilon > 0, \exists \delta > 0",
4200            r"\neg p \vee q \iff p \implies q",
4201            r"\gcd(a, b) \cdot \mathrm{lcm}(a, b) = a \cdot b",
4202            r"\Pr(A \cup B) \leq \Pr(A) + \Pr(B)",
4203            r"\liminf_{n \to \infty} a_n \leq \limsup_{n \to \infty} a_n",
4204            r"\sin^2\theta + \cos^2\theta = 1, \quad \tan\theta = \frac{\sin\theta}{\cos\theta}",
4205            r"\arcsin x + \arccos x = \frac{\pi}{2}",
4206            r"\sinh x = \frac{e^x - e^{-x}}{2}, \quad \cosh x = \frac{e^x + e^{-x}}{2}",
4207            r"\Pi_{i} a_i \prec \Sigma_{i} a_i \succ \Phi_{i} a_i",
4208            r"a \parallel b, \quad u \perp v",
4209            r"\vec{v} \cdot \vec{w} = \|\vec{v}\| \|\vec{w}\| \cos\theta",
4210            r"x \ll y \ll z, \quad a \gg b \gg c",
4211            r"\aleph_0 < 2^{\aleph_0}",
4212            r"\Im(z) + i\,\Re(z), \quad \ell^2(\mathbb{N})",
4213            r"x \star y \ne y \star x, \quad a \oplus b \otimes c",
4214            r"p \models \phi \vdash \psi \dashv \rho",
4215        ];
4216
4217        for formula in formulas {
4218            let expr = parse_tex(formula)
4219                .unwrap_or_else(|err| panic!("failed to parse {formula:?}: {}", err.message));
4220            assert_no_unknown_tex_commands(&expr);
4221            let layout = layout_math(&expr, 16.0, MathDisplay::Block);
4222            assert!(
4223                layout.width.is_finite() && layout.height().is_finite(),
4224                "layout finite for {formula:?}"
4225            );
4226        }
4227    }
4228
4229    #[test]
4230    fn display_sum_scripts_layout_as_limits() {
4231        let expr = parse_tex(r"\sum_{i=1}^{n} x_i").expect("valid tex");
4232        let layout = layout_math(&expr, 16.0, MathDisplay::Block);
4233        let metrics = LayoutCtx {
4234            size: 16.0,
4235            display: MathDisplay::Block,
4236        }
4237        .metrics();
4238        let sum_center_y = layout
4239            .atoms
4240            .iter()
4241            .find_map(|atom| match atom {
4242                MathAtom::Glyph {
4243                    text, y_baseline, ..
4244                } if text == "∑" => Some(*y_baseline),
4245                MathAtom::GlyphId { rect, .. } => Some(rect.y + rect.h * 0.5),
4246                _ => None,
4247            })
4248            .expect("sum center");
4249        let upper_y = layout
4250            .atoms
4251            .iter()
4252            .find_map(|atom| match atom {
4253                MathAtom::Glyph {
4254                    text, y_baseline, ..
4255                } if text == "n" => Some(*y_baseline),
4256                _ => None,
4257            })
4258            .expect("upper limit baseline");
4259        let lower_y = layout
4260            .atoms
4261            .iter()
4262            .find_map(|atom| match atom {
4263                MathAtom::Glyph {
4264                    text, y_baseline, ..
4265                } if text == "i" => Some(*y_baseline),
4266                _ => None,
4267            })
4268            .expect("lower limit baseline");
4269        assert!(
4270            layout
4271                .atoms
4272                .iter()
4273                .any(|atom| matches!(atom, MathAtom::Glyph { text, y_baseline, .. } if text == "n" && *y_baseline < 0.0)),
4274            "sum upper limit should sit above the operator"
4275        );
4276        assert!(
4277            layout
4278                .atoms
4279                .iter()
4280                .any(|atom| matches!(atom, MathAtom::Glyph { text, y_baseline, .. } if text == "i" && *y_baseline > 0.0)),
4281            "sum lower limit should sit below the operator"
4282        );
4283        assert!(
4284            sum_center_y - upper_y >= metrics.upper_limit_baseline_rise() - 0.1,
4285            "upper limit rise = {}, min = {}",
4286            sum_center_y - upper_y,
4287            metrics.upper_limit_baseline_rise()
4288        );
4289        assert!(
4290            lower_y - sum_center_y >= metrics.lower_limit_baseline_drop() - 0.1,
4291            "lower limit drop = {}, min = {}",
4292            lower_y - sum_center_y,
4293            metrics.lower_limit_baseline_drop()
4294        );
4295        assert!(
4296            layout
4297                .atoms
4298                .iter()
4299                .any(|atom| matches!(atom, MathAtom::GlyphId { .. })),
4300            "display sum should use an OpenType operator variant"
4301        );
4302        assert!(
4303            (sum_center_y + metrics.math_axis_shift()).abs() < 0.75,
4304            "display sum should center on the parent math axis"
4305        );
4306    }
4307
4308    #[test]
4309    fn display_integral_uses_open_type_variant() {
4310        let display = layout_math(&parse_tex(r"\int").unwrap(), 16.0, MathDisplay::Block);
4311        let inline = layout_math(&parse_tex(r"\int").unwrap(), 16.0, MathDisplay::Inline);
4312        assert!(
4313            display
4314                .atoms
4315                .iter()
4316                .any(|atom| matches!(atom, MathAtom::GlyphId { .. })),
4317            "display integral should use an OpenType operator variant"
4318        );
4319        assert!(
4320            display.height() > inline.height() * 1.4,
4321            "display integral height = {}, inline height = {}",
4322            display.height(),
4323            inline.height()
4324        );
4325    }
4326
4327    #[test]
4328    fn mathml_largeop_false_keeps_integral_unexpanded() {
4329        let expr = parse_mathml(r#"<math><mo largeop="false">∫</mo></math>"#)
4330            .expect("valid MathML integral");
4331        let layout = layout_math(&expr, 16.0, MathDisplay::Block);
4332        assert!(
4333            !layout
4334                .atoms
4335                .iter()
4336                .any(|atom| matches!(atom, MathAtom::GlyphId { .. })),
4337            "largeop=false should keep display integral on the ordinary glyph path"
4338        );
4339    }
4340
4341    #[test]
4342    fn display_integral_scripts_stay_on_side_of_large_operator() {
4343        let layout = layout_math(
4344            &parse_tex(r"\int_0^1 f(x)dx").unwrap(),
4345            16.0,
4346            MathDisplay::Block,
4347        );
4348        let integral_rect = layout
4349            .atoms
4350            .iter()
4351            .find_map(|atom| match atom {
4352                MathAtom::GlyphId { rect, .. } => Some(*rect),
4353                _ => None,
4354            })
4355            .expect("large integral glyph");
4356        let lower = layout
4357            .atoms
4358            .iter()
4359            .find_map(|atom| match atom {
4360                MathAtom::Glyph { text, x, .. } if text == "0" => Some(*x),
4361                _ => None,
4362            })
4363            .expect("lower integral script");
4364        let upper = layout
4365            .atoms
4366            .iter()
4367            .find_map(|atom| match atom {
4368                MathAtom::Glyph { text, x, .. } if text == "1" => Some(*x),
4369                _ => None,
4370            })
4371            .expect("upper integral script");
4372
4373        assert!(
4374            lower >= integral_rect.right() - 0.5 && upper >= integral_rect.right() - 0.5,
4375            "integral scripts should stay to the side, rect = {integral_rect:?}, lower x = {lower}, upper x = {upper}"
4376        );
4377    }
4378
4379    #[test]
4380    fn parses_tex_left_right_fences() {
4381        let expr = parse_tex(r"\left(\frac{a}{b}\right)").expect("valid fenced tex");
4382        match expr {
4383            MathExpr::Fenced { open, close, body } => {
4384                assert_eq!(open.as_deref(), Some("("));
4385                assert_eq!(close.as_deref(), Some(")"));
4386                assert!(matches!(*body, MathExpr::Fraction { .. }));
4387            }
4388            other => panic!("expected fenced expression, got {other:?}"),
4389        }
4390        let layout = layout_math(
4391            &parse_tex(r"\left(\begin{matrix}a\\b\\c\end{matrix}\right)").unwrap(),
4392            16.0,
4393            MathDisplay::Inline,
4394        );
4395        assert!(
4396            layout
4397                .atoms
4398                .iter()
4399                .any(|atom| matches!(atom, MathAtom::GlyphId { rect, .. } if rect.h > 16.0)),
4400            "fence should emit a stretched OpenType delimiter variant glyph"
4401        );
4402    }
4403
4404    #[test]
4405    fn simple_tex_left_right_fences_remain_glyphs() {
4406        let layout = layout_math(
4407            &parse_tex(r"\left(x\right)").unwrap(),
4408            16.0,
4409            MathDisplay::Inline,
4410        );
4411        assert!(
4412            !layout
4413                .atoms
4414                .iter()
4415                .any(|atom| matches!(atom, MathAtom::Delimiter { .. })),
4416            "simple fences should stay as glyphs below the font stretch threshold"
4417        );
4418        assert!(
4419            layout
4420                .atoms
4421                .iter()
4422                .any(|atom| matches!(atom, MathAtom::Glyph { text, .. } if text == "(")),
4423            "left fence should emit a glyph atom"
4424        );
4425        assert!(
4426            layout
4427                .atoms
4428                .iter()
4429                .any(|atom| matches!(atom, MathAtom::Glyph { text, .. } if text == ")")),
4430            "right fence should emit a glyph atom"
4431        );
4432    }
4433
4434    #[test]
4435    fn stretched_tex_fences_use_open_type_variant_glyphs() {
4436        let layout = layout_math(
4437            &parse_tex(r"\left(\begin{matrix}a&b\\c&d\end{matrix}\right)").unwrap(),
4438            16.0,
4439            MathDisplay::Inline,
4440        );
4441        assert!(
4442            layout
4443                .atoms
4444                .iter()
4445                .any(|atom| matches!(atom, MathAtom::GlyphId { .. })),
4446            "moderately stretched fences should use exact OpenType delimiter variant glyphs"
4447        );
4448    }
4449
4450    #[test]
4451    fn very_tall_tex_fences_use_open_type_assembly_parts() {
4452        let expr =
4453            parse_tex(r"\left\{\begin{matrix}a\\b\\c\\d\\e\\f\\g\\h\end{matrix}\right.").unwrap();
4454        let layout = layout_math(&expr, 16.0, MathDisplay::Inline);
4455        let glyph_id_count = layout
4456            .atoms
4457            .iter()
4458            .filter(|atom| matches!(atom, MathAtom::GlyphId { .. }))
4459            .count();
4460        assert!(
4461            glyph_id_count > 2,
4462            "very tall fences should use repeated OpenType assembly glyph parts"
4463        );
4464        assert!(
4465            !layout
4466                .atoms
4467                .iter()
4468                .any(|atom| matches!(atom, MathAtom::Delimiter { .. })),
4469            "font assembly should avoid the hand-drawn delimiter fallback"
4470        );
4471        let MathExpr::Fenced { body, .. } = expr else {
4472            panic!("expected fenced expression");
4473        };
4474        let ctx = LayoutCtx {
4475            size: 16.0,
4476            display: MathDisplay::Inline,
4477        };
4478        let target_rect = delimiter_rect(&layout_expr(&body, ctx), ctx);
4479        let assembled_top = layout
4480            .atoms
4481            .iter()
4482            .filter_map(|atom| match atom {
4483                MathAtom::GlyphId { rect, .. } => Some(rect.y),
4484                _ => None,
4485            })
4486            .fold(f32::INFINITY, f32::min);
4487        let assembled_bottom = layout
4488            .atoms
4489            .iter()
4490            .filter_map(|atom| match atom {
4491                MathAtom::GlyphId { rect, .. } => Some(rect.y + rect.h),
4492                _ => None,
4493            })
4494            .fold(f32::NEG_INFINITY, f32::max);
4495        assert!(
4496            assembled_bottom - assembled_top <= target_rect.h + 0.5,
4497            "assembled delimiter height should track target height"
4498        );
4499    }
4500
4501    #[test]
4502    fn rejects_unmatched_tex_right_fence() {
4503        let err = parse_tex(r"x \right)").expect_err("invalid unmatched fence");
4504        assert!(err.message.contains("unexpected \\right"));
4505    }
4506
4507    #[test]
4508    fn parses_tex_matrix_environment() {
4509        let expr = parse_tex(r"\begin{matrix}a&b\\c&d\end{matrix}").expect("valid matrix");
4510        match expr {
4511            MathExpr::Table {
4512                rows,
4513                column_alignments,
4514                ..
4515            } => {
4516                assert_eq!(rows.len(), 2);
4517                assert_eq!(rows[0].len(), 2);
4518                assert_eq!(rows[1].len(), 2);
4519                assert_eq!(rows[0][0], MathExpr::Identifier("a".into()));
4520                assert_eq!(rows[1][1], MathExpr::Identifier("d".into()));
4521                assert!(column_alignments.is_empty());
4522            }
4523            other => panic!("expected table expression, got {other:?}"),
4524        }
4525    }
4526
4527    #[test]
4528    fn parses_tex_bmatrix_as_fenced_table() {
4529        let expr =
4530            parse_tex(r"\begin{bmatrix}a&b\\c&d\end{bmatrix}").expect("valid bracketed matrix");
4531        match expr {
4532            MathExpr::Fenced { open, close, body } => {
4533                assert_eq!(open.as_deref(), Some("["));
4534                assert_eq!(close.as_deref(), Some("]"));
4535                match body.as_ref() {
4536                    MathExpr::Table { rows, .. } => {
4537                        assert_eq!(rows.len(), 2);
4538                        assert_eq!(rows[0].len(), 2);
4539                    }
4540                    other => panic!("expected table body, got {other:?}"),
4541                }
4542            }
4543            other => panic!("expected fenced matrix, got {other:?}"),
4544        }
4545    }
4546
4547    #[test]
4548    fn parses_tex_cases_as_left_braced_table() {
4549        let expr = parse_tex(r"\begin{cases}x&x>0\\-x&x<0\end{cases}").expect("valid cases");
4550        match expr {
4551            MathExpr::Fenced { open, close, body } => {
4552                assert_eq!(open.as_deref(), Some("{"));
4553                assert_eq!(close.as_deref(), None);
4554                match body.as_ref() {
4555                    MathExpr::Table {
4556                        column_alignments,
4557                        column_gap,
4558                        ..
4559                    } => {
4560                        assert_eq!(
4561                            column_alignments,
4562                            &vec![MathColumnAlignment::Left, MathColumnAlignment::Left]
4563                        );
4564                        assert_eq!(*column_gap, Some(CASES_COL_GAP_EM));
4565                    }
4566                    other => panic!("expected table body, got {other:?}"),
4567                }
4568            }
4569            other => panic!("expected left-braced cases table, got {other:?}"),
4570        }
4571    }
4572
4573    #[test]
4574    fn parses_tex_array_column_alignments() {
4575        let expr = parse_tex(r"\begin{array}{lr}x&100\\xx&2\end{array}").expect("valid array");
4576        match expr {
4577            MathExpr::Table {
4578                rows,
4579                column_alignments,
4580                ..
4581            } => {
4582                assert_eq!(rows.len(), 2);
4583                assert_eq!(
4584                    column_alignments,
4585                    vec![MathColumnAlignment::Left, MathColumnAlignment::Right]
4586                );
4587            }
4588            other => panic!("expected array table, got {other:?}"),
4589        }
4590    }
4591
4592    #[test]
4593    fn ignores_trailing_tex_table_row_separator() {
4594        let expr = parse_tex(r"\begin{matrix}a&b\\c&d\\\end{matrix}")
4595            .expect("valid matrix with trailing row separator");
4596        match expr {
4597            MathExpr::Table { rows, .. } => {
4598                assert_eq!(rows.len(), 2);
4599                assert_eq!(rows[0].len(), 2);
4600                assert_eq!(rows[1].len(), 2);
4601            }
4602            other => panic!("expected table expression, got {other:?}"),
4603        }
4604    }
4605
4606    #[test]
4607    fn rejects_inconsistent_tex_table_columns() {
4608        let err =
4609            parse_tex(r"\begin{matrix}a&b\\c\end{matrix}").expect_err("invalid ragged matrix");
4610        assert!(err.message.contains("inconsistent column count"));
4611    }
4612
4613    #[test]
4614    fn rejects_mismatched_tex_array_alignment_spec() {
4615        let err = parse_tex(r"\begin{array}{lr}x&100&z\\xx&2&y\end{array}")
4616            .expect_err("invalid array alignment spec");
4617        assert!(err.message.contains("alignment spec has 2 columns"));
4618    }
4619
4620    #[test]
4621    fn table_layout_honors_column_alignment() {
4622        let left_aligned = layout_math(
4623            &MathExpr::Table {
4624                rows: vec![
4625                    vec![MathExpr::Identifier("x".into())],
4626                    vec![MathExpr::Identifier("xxxx".into())],
4627                ],
4628                column_alignments: vec![MathColumnAlignment::Left],
4629                column_gap: None,
4630                row_gap: None,
4631            },
4632            16.0,
4633            MathDisplay::Inline,
4634        );
4635        let right_aligned = layout_math(
4636            &MathExpr::Table {
4637                rows: vec![
4638                    vec![MathExpr::Identifier("x".into())],
4639                    vec![MathExpr::Identifier("xxxx".into())],
4640                ],
4641                column_alignments: vec![MathColumnAlignment::Right],
4642                column_gap: None,
4643                row_gap: None,
4644            },
4645            16.0,
4646            MathDisplay::Inline,
4647        );
4648        let left_x = left_aligned
4649            .atoms
4650            .iter()
4651            .find_map(|atom| match atom {
4652                MathAtom::Glyph { text, x, .. } if text == "x" => Some(*x),
4653                _ => None,
4654            })
4655            .expect("left-aligned first cell glyph");
4656        let right_x = right_aligned
4657            .atoms
4658            .iter()
4659            .find_map(|atom| match atom {
4660                MathAtom::Glyph { text, x, .. } if text == "x" => Some(*x),
4661                _ => None,
4662            })
4663            .expect("right-aligned first cell glyph");
4664
4665        assert!(left_x < 0.1, "left-aligned glyph x = {left_x}");
4666        assert!(
4667            right_x > left_x + 10.0,
4668            "right alignment should shift narrow cells across wider columns"
4669        );
4670    }
4671
4672    #[test]
4673    fn table_layout_honors_table_spacing() {
4674        let loose = layout_math(
4675            &MathExpr::Table {
4676                rows: vec![
4677                    vec![
4678                        MathExpr::Identifier("a".into()),
4679                        MathExpr::Identifier("b".into()),
4680                    ],
4681                    vec![
4682                        MathExpr::Identifier("c".into()),
4683                        MathExpr::Identifier("d".into()),
4684                    ],
4685                ],
4686                column_alignments: Vec::new(),
4687                column_gap: Some(2.0),
4688                row_gap: Some(1.0),
4689            },
4690            16.0,
4691            MathDisplay::Inline,
4692        );
4693        let tight = layout_math(
4694            &MathExpr::Table {
4695                rows: vec![
4696                    vec![
4697                        MathExpr::Identifier("a".into()),
4698                        MathExpr::Identifier("b".into()),
4699                    ],
4700                    vec![
4701                        MathExpr::Identifier("c".into()),
4702                        MathExpr::Identifier("d".into()),
4703                    ],
4704                ],
4705                column_alignments: Vec::new(),
4706                column_gap: Some(0.25),
4707                row_gap: Some(0.1),
4708            },
4709            16.0,
4710            MathDisplay::Inline,
4711        );
4712
4713        assert!(
4714            loose.width > tight.width + 20.0,
4715            "loose width = {}, tight width = {}",
4716            loose.width,
4717            tight.width
4718        );
4719        assert!(
4720            loose.height() > tight.height() + 10.0,
4721            "loose height = {}, tight height = {}",
4722            loose.height(),
4723            tight.height()
4724        );
4725    }
4726
4727    #[test]
4728    fn table_layout_centers_on_math_axis() {
4729        let layout = layout_math(
4730            &MathExpr::Table {
4731                rows: vec![
4732                    vec![
4733                        MathExpr::Identifier("a".into()),
4734                        MathExpr::Identifier("b".into()),
4735                    ],
4736                    vec![
4737                        MathExpr::Identifier("c".into()),
4738                        MathExpr::Identifier("d".into()),
4739                    ],
4740                ],
4741                column_alignments: Vec::new(),
4742                column_gap: None,
4743                row_gap: None,
4744            },
4745            16.0,
4746            MathDisplay::Block,
4747        );
4748        let visual_center_y = (layout.descent - layout.ascent) * 0.5;
4749        assert!(
4750            visual_center_y < -2.0,
4751            "table visual center should sit on the math axis above baseline, got {visual_center_y}"
4752        );
4753    }
4754
4755    #[test]
4756    fn math_axis_prefers_open_type_axis_height() {
4757        let size = 14.0;
4758        let metrics = LayoutCtx {
4759            size,
4760            display: MathDisplay::Block,
4761        }
4762        .metrics();
4763        let expected = metrics
4764            .font_constants()
4765            .and_then(|constants| constants.axis_height(size))
4766            .unwrap_or_else(|| {
4767                metrics
4768                    .operator_axis_shift()
4769                    .expect("operator axis fallback")
4770            });
4771
4772        assert!(
4773            (metrics.math_axis_shift() - expected).abs() < 0.1,
4774            "axis = {}, expected = {expected}",
4775            metrics.math_axis_shift()
4776        );
4777    }
4778
4779    #[test]
4780    fn rejects_mismatched_tex_environment_end() {
4781        let err = parse_tex(r"\begin{matrix}a\end{pmatrix}").expect_err("invalid environment");
4782        assert!(err.message.contains(r"expected \end{matrix}"));
4783    }
4784
4785    #[test]
4786    fn reports_unclosed_group() {
4787        let err = parse_tex(r"\frac{1}{x").expect_err("invalid tex");
4788        assert!(err.message.contains("unclosed group"));
4789    }
4790
4791    #[test]
4792    fn parses_mathml_fraction_with_scripts() {
4793        let expr = parse_mathml(
4794            r#"
4795            <math>
4796              <mfrac>
4797                <mrow>
4798                  <msup><mi>a</mi><mn>2</mn></msup>
4799                  <mo>+</mo>
4800                  <msup><mi>b</mi><mn>2</mn></msup>
4801                </mrow>
4802                <msqrt>
4803                  <msub><mi>x</mi><mn>1</mn></msub>
4804                  <mo>+</mo>
4805                  <msub><mi>x</mi><mn>2</mn></msub>
4806                </msqrt>
4807              </mfrac>
4808            </math>
4809            "#,
4810        )
4811        .expect("valid mathml");
4812        let layout = layout_math(&expr, 16.0, MathDisplay::Block);
4813        assert!(layout.width > 20.0, "width = {}", layout.width);
4814        assert!(
4815            layout
4816                .atoms
4817                .iter()
4818                .any(|atom| matches!(atom, MathAtom::Rule { .. })),
4819            "fraction should emit rule atoms"
4820        );
4821        assert!(
4822            has_radical_shape(&layout),
4823            "sqrt should emit a radical shape atom"
4824        );
4825    }
4826
4827    #[test]
4828    fn parses_mathml_indexed_root() {
4829        let expr = parse_mathml(
4830            r#"
4831            <math>
4832              <mroot>
4833                <mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
4834                <mn>3</mn>
4835              </mroot>
4836            </math>
4837            "#,
4838        )
4839        .expect("valid mathml");
4840        match expr {
4841            MathExpr::Root { base, index } => {
4842                assert_eq!(*index, MathExpr::Number("3".into()));
4843                assert!(matches!(*base, MathExpr::Row(_)));
4844            }
4845            other => panic!("expected indexed root, got {other:?}"),
4846        }
4847    }
4848
4849    #[test]
4850    fn parses_mathml_under_over() {
4851        let expr = parse_mathml(
4852            r#"
4853            <math>
4854              <munderover>
4855                <mo>∑</mo>
4856                <mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow>
4857                <mi>n</mi>
4858              </munderover>
4859            </math>
4860            "#,
4861        )
4862        .expect("valid mathml");
4863        match expr {
4864            MathExpr::UnderOver { base, under, over } => {
4865                assert_eq!(*base, MathExpr::Operator("∑".into()));
4866                assert!(matches!(*under.unwrap(), MathExpr::Row(_)));
4867                assert_eq!(*over.unwrap(), MathExpr::Identifier("n".into()));
4868            }
4869            other => panic!("expected under/over expression, got {other:?}"),
4870        }
4871    }
4872
4873    #[test]
4874    fn parses_mathml_operator_spacing_attributes() {
4875        let expr = parse_mathml(r#"<math><mo lspace="0em" rspace="0.5em">+</mo></math>"#)
4876            .expect("valid spaced operator");
4877        assert_eq!(
4878            expr,
4879            MathExpr::OperatorWithMetadata {
4880                text: "+".into(),
4881                lspace: Some(0.0),
4882                rspace: Some(0.5),
4883                large_operator: None,
4884                movable_limits: None,
4885            }
4886        );
4887
4888        let default_width =
4889            layout_math(&MathExpr::Operator("+".into()), 16.0, MathDisplay::Inline).width;
4890        let custom_width = layout_math(&expr, 16.0, MathDisplay::Inline).width;
4891        assert!(
4892            custom_width > default_width,
4893            "custom width = {custom_width}, default width = {default_width}"
4894        );
4895    }
4896
4897    #[test]
4898    fn parses_mathml_operator_limit_attributes() {
4899        let expr = parse_mathml(
4900            r#"
4901            <math>
4902              <msub>
4903                <mo movablelimits="true">lim</mo>
4904                <mi>x</mi>
4905              </msub>
4906            </math>
4907            "#,
4908        )
4909        .expect("valid movable limits operator");
4910        let layout = layout_math(&expr, 16.0, MathDisplay::Block);
4911        assert!(
4912            layout
4913                .atoms
4914                .iter()
4915                .any(|atom| matches!(atom, MathAtom::Glyph { text, y_baseline, .. } if text == "x" && *y_baseline > 0.0)),
4916            "movablelimits operator should place display subscript underneath"
4917        );
4918
4919        let large = parse_mathml(r#"<math><mo largeop="true">∫</mo></math>"#)
4920            .expect("valid large operator");
4921        assert!(matches!(
4922            large,
4923            MathExpr::OperatorWithMetadata {
4924                large_operator: Some(true),
4925                ..
4926            }
4927        ));
4928    }
4929
4930    #[test]
4931    fn parses_mathml_accent_mover() {
4932        let expr = parse_mathml(
4933            r#"
4934            <math>
4935              <mover accent="true">
4936                <mi>x</mi>
4937                <mo>^</mo>
4938              </mover>
4939            </math>
4940            "#,
4941        )
4942        .expect("valid mathml accent");
4943        match expr {
4944            MathExpr::Accent {
4945                base,
4946                accent,
4947                stretch,
4948            } => {
4949                assert_eq!(*base, MathExpr::Identifier("x".into()));
4950                assert_eq!(*accent, MathExpr::Operator("^".into()));
4951                assert!(!stretch);
4952            }
4953            other => panic!("expected accent expression, got {other:?}"),
4954        }
4955    }
4956
4957    #[test]
4958    fn parses_mathml_semantics_wrapper() {
4959        let expr = parse_mathml(
4960            r#"
4961            <math>
4962              <semantics>
4963                <mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
4964                <annotation encoding="application/x-tex">x+1</annotation>
4965              </semantics>
4966            </math>
4967            "#,
4968        )
4969        .expect("valid mathml semantics wrapper");
4970        match expr {
4971            MathExpr::Row(children) => {
4972                assert_eq!(children.len(), 3);
4973                assert_eq!(children[0], MathExpr::Identifier("x".into()));
4974                assert_eq!(children[2], MathExpr::Number("1".into()));
4975            }
4976            other => panic!("expected row expression, got {other:?}"),
4977        }
4978    }
4979
4980    #[test]
4981    fn rejects_mathml_semantics_without_presentation_child() {
4982        let err = parse_mathml(
4983            r#"
4984            <math>
4985              <semantics>
4986                <annotation encoding="application/x-tex">x+1</annotation>
4987              </semantics>
4988            </math>
4989            "#,
4990        )
4991        .expect_err("invalid mathml semantics wrapper");
4992        assert!(
4993            err.message
4994                .contains("<semantics> expected a presentation child")
4995        );
4996    }
4997
4998    #[test]
4999    fn parses_mathml_fenced_expression() {
5000        let expr = parse_mathml(
5001            r#"
5002            <math>
5003              <mfenced open="[" close="]" separators=",">
5004                <mi>a</mi>
5005                <mi>b</mi>
5006              </mfenced>
5007            </math>
5008            "#,
5009        )
5010        .expect("valid mathml fenced expression");
5011        match expr {
5012            MathExpr::Fenced { open, close, body } => {
5013                assert_eq!(open.as_deref(), Some("["));
5014                assert_eq!(close.as_deref(), Some("]"));
5015                match body.as_ref() {
5016                    MathExpr::Row(children) => {
5017                        assert_eq!(children.len(), 3);
5018                        assert_eq!(children[1], MathExpr::Operator(",".into()));
5019                    }
5020                    other => panic!("expected row body, got {other:?}"),
5021                }
5022            }
5023            other => panic!("expected fenced expression, got {other:?}"),
5024        }
5025    }
5026
5027    #[test]
5028    fn parses_mathml_table() {
5029        let expr = parse_mathml(
5030            r#"
5031            <math>
5032              <mtable>
5033                <mtr>
5034                  <mtd><mi>a</mi></mtd>
5035                  <mtd><mi>b</mi></mtd>
5036                </mtr>
5037                <mtr>
5038                  <mtd><mi>c</mi></mtd>
5039                  <mtd><mi>d</mi></mtd>
5040                </mtr>
5041              </mtable>
5042            </math>
5043            "#,
5044        )
5045        .expect("valid mathml");
5046        match expr {
5047            MathExpr::Table { rows, .. } => {
5048                assert_eq!(rows.len(), 2);
5049                assert_eq!(rows[0].len(), 2);
5050                assert_eq!(rows[1].len(), 2);
5051            }
5052            other => panic!("expected table expression, got {other:?}"),
5053        }
5054        let layout = layout_math(
5055            &parse_mathml(
5056                r#"<math><mtable><mtr><mtd><mi>a</mi></mtd><mtd><mi>b</mi></mtd></mtr><mtr><mtd><mi>c</mi></mtd><mtd><mi>d</mi></mtd></mtr></mtable></math>"#,
5057            )
5058            .unwrap(),
5059            16.0,
5060            MathDisplay::Block,
5061        );
5062        assert!(layout.width > 20.0, "width = {}", layout.width);
5063        assert!(layout.ascent > 10.0, "ascent = {}", layout.ascent);
5064        assert!(layout.descent > 10.0, "descent = {}", layout.descent);
5065    }
5066
5067    #[test]
5068    fn parses_mathml_table_column_alignment() {
5069        let expr = parse_mathml(
5070            r#"
5071            <math>
5072              <mtable columnalign="left right">
5073                <mtr>
5074                  <mtd><mi>x</mi></mtd>
5075                  <mtd><mn>100</mn></mtd>
5076                </mtr>
5077              </mtable>
5078            </math>
5079            "#,
5080        )
5081        .expect("valid aligned mathml table");
5082        match expr {
5083            MathExpr::Table {
5084                column_alignments, ..
5085            } => {
5086                assert_eq!(
5087                    column_alignments,
5088                    vec![MathColumnAlignment::Left, MathColumnAlignment::Right]
5089                );
5090            }
5091            other => panic!("expected table expression, got {other:?}"),
5092        }
5093    }
5094
5095    #[test]
5096    fn parses_mathml_table_spacing() {
5097        let expr = parse_mathml(
5098            r#"
5099            <math>
5100              <mtable columnspacing="0.5em" rowspacing="0.2em">
5101                <mtr>
5102                  <mtd><mi>a</mi></mtd>
5103                  <mtd><mi>b</mi></mtd>
5104                </mtr>
5105                <mtr>
5106                  <mtd><mi>c</mi></mtd>
5107                  <mtd><mi>d</mi></mtd>
5108                </mtr>
5109              </mtable>
5110            </math>
5111            "#,
5112        )
5113        .expect("valid spaced mathml table");
5114        match expr {
5115            MathExpr::Table {
5116                column_gap,
5117                row_gap,
5118                ..
5119            } => {
5120                assert_eq!(column_gap, Some(0.5));
5121                assert_eq!(row_gap, Some(0.2));
5122            }
5123            other => panic!("expected table expression, got {other:?}"),
5124        }
5125    }
5126
5127    #[test]
5128    fn parses_mathml_display_attribute() {
5129        let (expr, display) = parse_mathml_with_display(
5130            r#"<math display="block"><msubsup><mi>x</mi><mn>1</mn><mn>2</mn></msubsup></math>"#,
5131        )
5132        .expect("valid mathml");
5133        assert_eq!(display, MathDisplay::Block);
5134        match expr {
5135            MathExpr::Scripts { base, sub, sup } => {
5136                assert_eq!(*base, MathExpr::Identifier("x".into()));
5137                assert_eq!(*sub.unwrap(), MathExpr::Number("1".into()));
5138                assert_eq!(*sup.unwrap(), MathExpr::Number("2".into()));
5139            }
5140            other => panic!("expected scripts expression, got {other:?}"),
5141        }
5142    }
5143
5144    #[test]
5145    fn rejects_wrong_mathml_arity() {
5146        let err =
5147            parse_mathml(r#"<math><mfrac><mi>a</mi></mfrac></math>"#).expect_err("invalid arity");
5148        assert!(err.message.contains("expected 2 element children"));
5149    }
5150}