Skip to main content

fmd_math/
atom.rs

1//! The atom engine: TeX's eight atom classes, contextual Bin→Ord
2//! degradation, and the inter-atom spacing table.
3//!
4//! Everything here is TeX's *published* mathematics (The TeXbook, chapter 17
5//! and appendix G): the spacing table is transcribed entry-for-entry from
6//! page 170, and the two degradation rules are appendix G's rules 5 and 6.
7//! These tables are the normative contract the spacing fixtures lock.
8
9use crate::node::{Node, NodeKind, StackKind};
10use crate::style::Style;
11
12/// TeX's eight atom classes.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14pub enum AtomClass {
15    /// Ordinary: letters, digits, most symbols.
16    Ord,
17    /// Large operator: `\sum`, `\int`, `\lim`, …
18    Op,
19    /// Binary operation: `+`, `−`, `\times`, …
20    Bin,
21    /// Relation: `=`, `<`, `\le`, arrows, …
22    Rel,
23    /// Opening delimiter: `(`, `[`, `\langle`, …
24    Open,
25    /// Closing delimiter: `)`, `]`, `\rangle`, …
26    Close,
27    /// Punctuation: `,`, `;`, …
28    Punct,
29    /// Inner: fractions, `\left…\right` groups, `\ldots`-class dots.
30    Inner,
31}
32
33/// The eight classes in table order (the order of the spacing table's rows
34/// and columns).
35pub const ATOM_CLASSES: [AtomClass; 8] = [
36    AtomClass::Ord,
37    AtomClass::Op,
38    AtomClass::Bin,
39    AtomClass::Rel,
40    AtomClass::Open,
41    AtomClass::Close,
42    AtomClass::Punct,
43    AtomClass::Inner,
44];
45
46impl AtomClass {
47    /// Row/column index in the spacing table.
48    #[inline(always)]
49    #[must_use]
50    pub const fn index(self) -> usize {
51        match self {
52            Self::Ord => 0,
53            Self::Op => 1,
54            Self::Bin => 2,
55            Self::Rel => 3,
56            Self::Open => 4,
57            Self::Close => 5,
58            Self::Punct => 6,
59            Self::Inner => 7,
60        }
61    }
62}
63
64/// The amount of glue between two adjacent atoms.
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum Spacing {
67    /// No space.
68    None,
69    /// Thin space: 3 mu.
70    Thin,
71    /// Medium space: 4 mu.
72    Med,
73    /// Thick space: 5 mu.
74    Thick,
75}
76
77impl Spacing {
78    /// The glue amount in mu (1 mu = 1/18 em at the current size).
79    #[inline(always)]
80    #[must_use]
81    pub const fn mu(self) -> i32 {
82        match self {
83            Self::None => 0,
84            Self::Thin => 3,
85            Self::Med => 4,
86            Self::Thick => 5,
87        }
88    }
89}
90
91/// One entry of the spacing table.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum PairSpacing {
94    /// Space inserted in every style.
95    Always(Spacing),
96    /// Space inserted only in display and text styles (the TeXbook's
97    /// parenthesized entries): suppressed in script and scriptscript.
98    DisplayTextOnly(Spacing),
99    /// The pair cannot occur after Bin→Ord degradation (the TeXbook's `*`
100    /// entries).
101    Impossible,
102}
103
104use PairSpacing::{Always as A, DisplayTextOnly as P, Impossible as X};
105use Spacing::{Med, None as N0, Thick, Thin};
106
107/// The inter-atom spacing table, exactly as published (The TeXbook,
108/// p. 170). Rows are the left atom's class, columns the right atom's class,
109/// both in [`ATOM_CLASSES`] order.
110pub const SPACING_TABLE: [[PairSpacing; 8]; 8] = [
111    // right:  Ord       Op        Bin      Rel       Open      Close     Punct     Inner
112    /* Ord   */
113    [
114        A(N0),
115        A(Thin),
116        P(Med),
117        P(Thick),
118        A(N0),
119        A(N0),
120        A(N0),
121        P(Thin),
122    ],
123    /* Op    */
124    [A(Thin), A(Thin), X, P(Thick), A(N0), A(N0), A(N0), P(Thin)],
125    /* Bin   */
126    [P(Med), P(Med), X, X, P(Med), X, X, P(Med)],
127    /* Rel   */
128    [
129        P(Thick),
130        P(Thick),
131        X,
132        A(N0),
133        P(Thick),
134        A(N0),
135        A(N0),
136        P(Thick),
137    ],
138    /* Open  */
139    [A(N0), A(N0), X, A(N0), A(N0), A(N0), A(N0), A(N0)],
140    /* Close */
141    [
142        A(N0),
143        A(Thin),
144        P(Med),
145        P(Thick),
146        A(N0),
147        A(N0),
148        A(N0),
149        P(Thin),
150    ],
151    /* Punct */
152    [
153        P(Thin),
154        P(Thin),
155        X,
156        P(Thin),
157        P(Thin),
158        P(Thin),
159        P(Thin),
160        P(Thin),
161    ],
162    /* Inner */
163    [
164        P(Thin),
165        A(Thin),
166        P(Med),
167        P(Thick),
168        P(Thin),
169        A(N0),
170        P(Thin),
171        P(Thin),
172    ],
173];
174
175/// The raw table entry for a pair.
176#[inline(always)]
177#[must_use]
178pub const fn pair_spacing(left: AtomClass, right: AtomClass) -> PairSpacing {
179    SPACING_TABLE[left.index()][right.index()]
180}
181
182/// The glue between two adjacent atoms in a given style, with the script
183/// suppression rule applied. Impossible pairs yield no space (the engine
184/// never produces them after degradation; tolerating them here keeps the
185/// function total for untrusted callers).
186#[inline(always)]
187#[must_use]
188pub const fn spacing_in_style(left: AtomClass, right: AtomClass, style: Style) -> Spacing {
189    match pair_spacing(left, right) {
190        PairSpacing::Always(s) => s,
191        PairSpacing::DisplayTextOnly(s) => {
192            if style.is_script() {
193                Spacing::None
194            } else {
195                s
196            }
197        }
198        PairSpacing::Impossible => Spacing::None,
199    }
200}
201
202/// The intrinsic atom class of a direct character in math mode, before any
203/// command mapping. Follows plain TeX's mathcode assignments.
204#[must_use]
205pub const fn char_class(ch: char) -> AtomClass {
206    match ch {
207        '+' | '−' | '-' | '∗' | '*' | '±' | '∓' | '×' | '⋅' | '÷' | '∘' | '∙' | '⊕' | '⊖' | '⊗'
208        | '⊘' | '⊙' | '∪' | '∩' | '∨' | '∧' | '∖' | '⋄' | '†' | '‡' | '⊎' | '⊔' | '⊓' | '≀'
209        | '⨿' | '⋆' | '◁' | '▷' => AtomClass::Bin,
210        '=' | '<' | '>' | ':' | '≤' | '≥' | '≠' | '≡' | '≈' | '∼' | '≃' | '≅' | '≐' | '∝' | '∈'
211        | '∋' | '∉' | '⊂' | '⊃' | '⊆' | '⊇' | '≪' | '≫' | '⊨' | '⊢' | '⊣' | '≍' | '∣' | '∥'
212        | '→' | '←' | '↔' | '⇒' | '⇐' | '⇔' | '⟶' | '⟵' | '⟷' | '⟹' | '⟸' | '⟺' | '↦' | '⟼'
213        | '↑' | '↓' | '↕' | '⇑' | '⇓' | '⇕' | '↗' | '↘' | '↙' | '↖' | '↪' | '↩' | '⇀' | '⇁'
214        | '↼' | '↽' | '⇌' => AtomClass::Rel,
215        '(' | '[' | '⟨' | '⌈' | '⌊' => AtomClass::Open,
216        ')' | ']' | '⟩' | '⌉' | '⌋' | '!' | '?' => AtomClass::Close,
217        ',' | ';' => AtomClass::Punct,
218        _ => AtomClass::Ord,
219    }
220}
221
222/// The intrinsic atom class a node contributes to its enclosing list, or
223/// `None` for non-atom items (spacing, ties, breaks, alignment tabs, and
224/// the style/color markers), which are transparent to both degradation and
225/// inter-atom spacing.
226#[must_use]
227pub fn intrinsic_class(node: &Node) -> Option<AtomClass> {
228    match &node.kind {
229        NodeKind::Symbol { class, .. } => Some(*class),
230        NodeKind::BigOp { .. } | NodeKind::OpName { .. } => Some(AtomClass::Op),
231        NodeKind::Scripts { base, .. } => base
232            .as_deref()
233            .map_or(Some(AtomClass::Ord), intrinsic_class),
234        NodeKind::Frac { .. } | NodeKind::LeftRight { .. } => Some(AtomClass::Inner),
235        NodeKind::SizedDelim { class, .. } => Some(*class),
236        NodeKind::Radical { .. }
237        | NodeKind::Accent { .. }
238        | NodeKind::List(_)
239        | NodeKind::Text { .. }
240        | NodeKind::TextRun { .. }
241        | NodeKind::TextStyled { .. }
242        | NodeKind::MathIsland { .. }
243        | NodeKind::AlignBlock { .. }
244        | NodeKind::Environment { .. } => Some(AtomClass::Ord),
245        NodeKind::MathFont { body, .. } | NodeKind::Phantom { body, .. } => intrinsic_class(body),
246        NodeKind::Stack { kind, base, .. } => match kind {
247            StackKind::Stackrel => Some(AtomClass::Rel),
248            StackKind::Overset | StackKind::Underset => intrinsic_class(base),
249        },
250        // The labeled extensible arrows space as relations, like the plain
251        // arrows they stretch.
252        NodeKind::XArrow { .. } => Some(AtomClass::Rel),
253        NodeKind::Fragment(kind) => match kind {
254            crate::node::FragmentKind::UnmatchedClose
255            | crate::node::FragmentKind::RedundantMathShift => None,
256            crate::node::FragmentKind::StrayRight(_) => Some(AtomClass::Close),
257        },
258        NodeKind::StyleChange(_)
259        | NodeKind::SizeChange(_)
260        | NodeKind::AlignChange(_)
261        | NodeKind::LineSpacing(_)
262        | NodeKind::ColorChange(_)
263        | NodeKind::Space(_)
264        | NodeKind::Tie
265        | NodeKind::Linebreak
266        | NodeKind::AlignTab => None,
267    }
268}
269
270/// Classify a horizontal list: every item's *effective* atom class, with
271/// TeX's Bin→Ord degradation rules applied (appendix G, rules 5, 6, and 7):
272///
273/// 1. a Bin atom that opens the list or follows a Bin, Op, Rel, Open, or
274///    Punct atom becomes Ord;
275/// 2. a Bin atom directly before a Rel, Close, or Punct atom becomes Ord;
276/// 3. a Bin atom at the end of the list becomes Ord.
277///
278/// Non-atom items yield `None` and are transparent: they neither receive a
279/// class nor interrupt atom adjacency.
280#[must_use]
281pub fn classify_list(items: &[Node]) -> Vec<Option<AtomClass>> {
282    let mut classes: Vec<Option<AtomClass>> = items.iter().map(intrinsic_class).collect();
283    let mut prev_atom: Option<usize> = None;
284    for i in 0..classes.len() {
285        let Some(current) = classes[i] else { continue };
286        if current == AtomClass::Bin {
287            let degrade = match prev_atom {
288                None => true,
289                Some(p) => matches!(
290                    classes[p],
291                    Some(
292                        AtomClass::Bin
293                            | AtomClass::Op
294                            | AtomClass::Rel
295                            | AtomClass::Open
296                            | AtomClass::Punct
297                    )
298                ),
299            };
300            if degrade {
301                classes[i] = Some(AtomClass::Ord);
302            }
303        } else if matches!(
304            current,
305            AtomClass::Rel | AtomClass::Close | AtomClass::Punct
306        ) {
307            if let Some(p) = prev_atom {
308                if classes[p] == Some(AtomClass::Bin) {
309                    classes[p] = Some(AtomClass::Ord);
310                }
311            }
312        }
313        prev_atom = Some(i);
314    }
315    if let Some(p) = prev_atom {
316        if classes[p] == Some(AtomClass::Bin) {
317            classes[p] = Some(AtomClass::Ord);
318        }
319    }
320    classes
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn spacing_table_diagonal_spot_checks() {
329        assert_eq!(pair_spacing(AtomClass::Ord, AtomClass::Op), A(Thin));
330        assert_eq!(pair_spacing(AtomClass::Bin, AtomClass::Bin), X);
331        assert_eq!(pair_spacing(AtomClass::Rel, AtomClass::Rel), A(N0));
332        assert_eq!(pair_spacing(AtomClass::Ord, AtomClass::Rel), P(Thick));
333    }
334
335    #[test]
336    fn script_styles_suppress_parenthesized_entries() {
337        assert_eq!(
338            spacing_in_style(AtomClass::Ord, AtomClass::Bin, Style::Text),
339            Med
340        );
341        assert_eq!(
342            spacing_in_style(AtomClass::Ord, AtomClass::Bin, Style::Script),
343            Spacing::None
344        );
345        assert_eq!(
346            spacing_in_style(AtomClass::Ord, AtomClass::Op, Style::ScriptScript),
347            Thin
348        );
349    }
350
351    #[test]
352    fn mu_values() {
353        assert_eq!(Spacing::Thin.mu(), 3);
354        assert_eq!(Spacing::Med.mu(), 4);
355        assert_eq!(Spacing::Thick.mu(), 5);
356        assert_eq!(Spacing::None.mu(), 0);
357    }
358}