Skip to main content

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