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