Skip to main content

damascene_core/
math.rs

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