Skip to main content

damascene_core/
math.rs

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