Skip to main content

fmd_math/
style.rs

1//! TeX's four math styles, cramping, and the propagation rules.
2//!
3//! Style propagation is exact TeX (The TeXbook, chapter 17 / appendix G):
4//! scripts go D,T → S → SS with subscripts cramped; fraction interiors go
5//! D → T → S → SS with denominators cramped; radicands and accent bases are
6//! cramped at the current style; radical indices are set in scriptscript
7//! style unconditionally. Glyph sizes follow CM's 10/7/5 pt family:
8//! 1.0 / 0.7 / 0.5.
9//!
10//! [`style_walk`] is the *normative* propagation definition: the layout
11//! stages consume the same walk, and the style-propagation fixtures lock it.
12
13use crate::node::{Node, NodeKind, StackKind};
14
15/// The four math styles (cramped variants ride [`StyleCtx`]).
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
17pub enum Style {
18    /// Display style.
19    Display,
20    /// Text (inline) style.
21    Text,
22    /// Script style (first-order scripts).
23    Script,
24    /// Scriptscript style (everything deeper).
25    ScriptScript,
26}
27
28impl Style {
29    /// True for the script styles, where medium/thick inter-atom spaces are
30    /// suppressed and `\sum`-class operators stop taking display limits.
31    #[must_use]
32    pub const fn is_script(self) -> bool {
33        matches!(self, Self::Script | Self::ScriptScript)
34    }
35
36    /// The glyph-size factor of the style relative to text size (CM's
37    /// 10 pt / 7 pt / 5 pt family).
38    #[must_use]
39    pub const fn size_factor(self) -> f64 {
40        match self {
41            Self::Display | Self::Text => 1.0,
42            Self::Script => 0.7,
43            Self::ScriptScript => 0.5,
44        }
45    }
46
47    /// The style of a superscript on an atom in `self`.
48    #[must_use]
49    pub const fn sup(self) -> Self {
50        match self {
51            Self::Display | Self::Text => Self::Script,
52            Self::Script | Self::ScriptScript => Self::ScriptScript,
53        }
54    }
55
56    /// The style of a fraction numerator in `self`.
57    #[must_use]
58    pub const fn num(self) -> Self {
59        match self {
60            Self::Display => Self::Text,
61            Self::Text => Self::Script,
62            Self::Script | Self::ScriptScript => Self::ScriptScript,
63        }
64    }
65}
66
67/// A style with its cramping state: the full layout context TeX threads
68/// top-down.
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
70pub struct StyleCtx {
71    /// The current style.
72    pub style: Style,
73    /// Cramped: superscripts are lowered (interiors of denominators,
74    /// subscripts, radicands, accent bases, …).
75    pub cramped: bool,
76}
77
78impl StyleCtx {
79    /// An uncramped context in the given style.
80    #[must_use]
81    pub const fn new(style: Style) -> Self {
82        Self {
83            style,
84            cramped: false,
85        }
86    }
87
88    /// Display, uncramped: the default whole-formula context.
89    #[must_use]
90    pub const fn display() -> Self {
91        Self::new(Style::Display)
92    }
93
94    /// The context of a superscript: style goes up one script level,
95    /// cramping is preserved.
96    #[must_use]
97    pub const fn sup(self) -> Self {
98        Self {
99            style: self.style.sup(),
100            cramped: self.cramped,
101        }
102    }
103
104    /// The context of a subscript: like [`Self::sup`] but always cramped.
105    #[must_use]
106    pub const fn sub(self) -> Self {
107        Self {
108            style: self.style.sup(),
109            cramped: true,
110        }
111    }
112
113    /// The context of a fraction numerator: style goes down one fraction
114    /// level, cramping preserved.
115    #[must_use]
116    pub const fn num(self) -> Self {
117        Self {
118            style: self.style.num(),
119            cramped: self.cramped,
120        }
121    }
122
123    /// The context of a fraction denominator: like [`Self::num`] but always
124    /// cramped.
125    #[must_use]
126    pub const fn den(self) -> Self {
127        Self {
128            style: self.style.num(),
129            cramped: true,
130        }
131    }
132
133    /// The same style, cramped (radicands, accent bases).
134    #[must_use]
135    pub const fn cramp(self) -> Self {
136        Self {
137            style: self.style,
138            cramped: true,
139        }
140    }
141
142    /// Glyph-size factor of the current style.
143    #[must_use]
144    pub const fn size_factor(self) -> f64 {
145        self.style.size_factor()
146    }
147}
148
149/// Walk `node` in pre-order, calling `visit` on every node with the style
150/// context it is laid out in. This is the normative propagation definition:
151///
152/// - scripts: base at the current context, superscript at [`StyleCtx::sup`],
153///   subscript at [`StyleCtx::sub`];
154/// - fractions: `\dfrac`/`\tfrac` force the fraction's own effective style
155///   first; numerator at `num`, denominator at `den` of the effective
156///   context;
157/// - radicals: radicand cramped at the current style, index in scriptscript
158///   style (rule 11);
159/// - accents: base cramped;
160/// - `\stackrel`/`\overset` annotations at `sup`, `\underset` at `sub`;
161/// - `\displaystyle`-class markers restyle the remainder of their enclosing
162///   list, preserving cramping;
163/// - `$…$` islands inside text are inline mathematics: they enter at text
164///   style, uncramped;
165/// - environment cells enter at text style (`array`/`matrix`-class and
166///   `cases` set `\textstyle`), except the `align`-class environments,
167///   whose cells keep the ambient context, and `substack`, whose lines
168///   are always set in script style (amsmath's `\subarray`);
169/// - everything else (groups, phantoms, math-font arguments, `\left…\right`
170///   bodies, `\text` islands) inherits the current context.
171pub fn style_walk<'a, F>(node: &'a Node, ctx: StyleCtx, visit: &mut F)
172where
173    F: FnMut(&'a Node, StyleCtx),
174{
175    visit(node, ctx);
176    match &node.kind {
177        NodeKind::List(items) => walk_list(items, ctx, visit),
178        NodeKind::Scripts { base, sub, sup, .. } => {
179            if let Some(b) = base {
180                style_walk(b, ctx, visit);
181            }
182            if let Some(s) = sup {
183                style_walk(s, ctx.sup(), visit);
184            }
185            if let Some(s) = sub {
186                style_walk(s, ctx.sub(), visit);
187            }
188        }
189        NodeKind::Frac { num, den, spec } => {
190            let eff = match spec.forced_style {
191                Some(forced) => StyleCtx {
192                    style: forced,
193                    cramped: ctx.cramped,
194                },
195                None => ctx,
196            };
197            style_walk(num, eff.num(), visit);
198            style_walk(den, eff.den(), visit);
199        }
200        NodeKind::Radical { index, radicand } => {
201            if let Some(ix) = index {
202                style_walk(
203                    ix,
204                    StyleCtx {
205                        style: Style::ScriptScript,
206                        cramped: ctx.cramped,
207                    },
208                    visit,
209                );
210            }
211            style_walk(radicand, ctx.cramp(), visit);
212        }
213        NodeKind::Accent { base, .. } => style_walk(base, ctx.cramp(), visit),
214        NodeKind::LeftRight { body, .. } => walk_list(body, ctx, visit),
215        NodeKind::Text { body } | NodeKind::TextStyled { body, .. } => {
216            walk_list(body, ctx, visit);
217        }
218        NodeKind::MathIsland { body, display } => {
219            let style = if *display {
220                Style::Display
221            } else {
222                Style::Text
223            };
224            walk_list(body, StyleCtx::new(style), visit);
225        }
226        NodeKind::MathFont { body, .. } | NodeKind::Phantom { body, .. } => {
227            style_walk(body, ctx, visit);
228        }
229        NodeKind::Stack {
230            kind,
231            annotation,
232            base,
233        } => {
234            let ann_ctx = match kind {
235                StackKind::Underset => ctx.sub(),
236                StackKind::Stackrel | StackKind::Overset => ctx.sup(),
237            };
238            style_walk(annotation, ann_ctx, visit);
239            style_walk(base, ctx, visit);
240        }
241        NodeKind::XArrow { above, below, .. } => {
242            // amsmath sets both labels in script(-of-current) style, the
243            // above-label uncramped, the below-label cramped like a sub.
244            style_walk(above, ctx.sup(), visit);
245            if let Some(below) = below {
246                style_walk(below, ctx.sub(), visit);
247            }
248        }
249        NodeKind::AlignBlock { lines, .. } => {
250            // Text-mode lines in the ambient style (like a `\text` body).
251            for line in lines {
252                style_walk(line, ctx, visit);
253            }
254        }
255        NodeKind::Environment { name, rows, .. } => {
256            let cell_ctx = if name == "substack" {
257                // amsmath's subarray: lines always in \scriptstyle,
258                // whatever the ambient style.
259                StyleCtx::new(Style::Script)
260            } else if name.starts_with("align") {
261                ctx
262            } else {
263                StyleCtx::new(Style::Text)
264            };
265            for row in rows {
266                for cell in row {
267                    style_walk(cell, cell_ctx, visit);
268                }
269            }
270        }
271        NodeKind::Symbol { .. }
272        | NodeKind::BigOp { .. }
273        | NodeKind::OpName { .. }
274        | NodeKind::SizedDelim { .. }
275        | NodeKind::TextRun { .. }
276        | NodeKind::StyleChange(_)
277        | NodeKind::SizeChange(_)
278        | NodeKind::AlignChange(_)
279        | NodeKind::LineSpacing(_)
280        | NodeKind::ColorChange(_)
281        | NodeKind::Space(_)
282        | NodeKind::Tie
283        | NodeKind::Linebreak
284        | NodeKind::AlignTab
285        | NodeKind::Fragment(_) => {}
286    }
287}
288
289/// Walk a horizontal list, honoring `\displaystyle`-class markers: a
290/// [`NodeKind::StyleChange`] restyles the remainder of the list (cramping
291/// preserved), exactly like TeX's style primitives.
292fn walk_list<'a, F>(items: &'a [Node], mut ctx: StyleCtx, visit: &mut F)
293where
294    F: FnMut(&'a Node, StyleCtx),
295{
296    for item in items {
297        if let NodeKind::StyleChange(style) = &item.kind {
298            visit(item, ctx);
299            ctx = StyleCtx {
300                style: *style,
301                cramped: ctx.cramped,
302            };
303            continue;
304        }
305        style_walk(item, ctx, visit);
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn sup_chain() {
315        assert_eq!(Style::Display.sup(), Style::Script);
316        assert_eq!(Style::Text.sup(), Style::Script);
317        assert_eq!(Style::Script.sup(), Style::ScriptScript);
318        assert_eq!(Style::ScriptScript.sup(), Style::ScriptScript);
319    }
320
321    #[test]
322    fn num_chain() {
323        assert_eq!(Style::Display.num(), Style::Text);
324        assert_eq!(Style::Text.num(), Style::Script);
325        assert_eq!(Style::Script.num(), Style::ScriptScript);
326        assert_eq!(Style::ScriptScript.num(), Style::ScriptScript);
327    }
328
329    #[test]
330    fn sub_is_cramped_sup() {
331        let ctx = StyleCtx::display();
332        assert_eq!(ctx.sub().style, Style::Script);
333        assert!(ctx.sub().cramped);
334        assert!(!ctx.sup().cramped);
335    }
336
337    #[test]
338    fn den_is_cramped_num() {
339        let ctx = StyleCtx::new(Style::Text);
340        assert_eq!(ctx.den().style, Style::Script);
341        assert!(ctx.den().cramped);
342    }
343
344    #[test]
345    fn size_factors_are_cm_10_7_5() {
346        assert_eq!(Style::Display.size_factor(), 1.0);
347        assert_eq!(Style::Text.size_factor(), 1.0);
348        assert_eq!(Style::Script.size_factor(), 0.7);
349        assert_eq!(Style::ScriptScript.size_factor(), 0.5);
350    }
351}